Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
ccaa7dc6cf test(handler): fix timeline test assertions for handler-package isolation
The TestListTimeline_* assertions assumed CreateIssue would seed an
"issue_created" activity_log row, but the activity listener that publishes
those rows is registered in cmd/server/main.go — handler-package tests
don't wire it up. CI saw 5 entries (3 comments + 2 activities) where the
test expected ≥6.

Drop the auto-activity assumption: assert exactly 5 entries in
TestListTimeline_MergesCommentsAndActivities, and tighten
TestListTimeline_EmptyIssue to assert a fully-empty timeline.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 16:02:05 +08:00
Jiang Bohan
08c7bf6fae fix(timeline): address review feedback on pagination removal
Three issues caught in PR #2322 review:

1. /timeline broke for stale clients between #2128 and this PR. They send
   ?limit/?before/?after/?around and parse with the wrapped TimelinePageSchema;
   the new flat-array response was failing schema validation and falling back
   to an empty timeline. Restore the wrapped shape on those query params
   (DESC entries, null cursors, has_more_*=false), keeping the flat ASC array
   for bare requests. Around-mode now also fills target_index from the merged
   slice so legacy clients can still scroll-to-anchor without a follow-up.

2. The agent prompts in runtime_config.go and prompt.go still told agents
   that `multica issue comment list` accepts --limit/--offset and to use
   `--limit 30` on truncated output. With those flags removed in this PR,
   new agent runs would hit "unknown flag" or skip context. Update the
   prompt copy to "returns all comments, capped at 2000; --since for
   incremental polling".

3. useCreateComment's onSuccess was a bare append to the timeline cache
   with no id-dedupe, so a fast comment:created WS event firing before
   onSuccess produced a transient duplicate. Restore the id guard the old
   prependToLatestPage helper used to provide.

Adds two new boundary tests:
- TestListTimeline_LegacyWrappedShape_OnPaginationParams
- TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 15:57:19 +08:00
Jiang Bohan
23a46d8123 refactor(timeline): drop server-side comment + timeline pagination (MUL-1929)
The cursor-paginated /timeline and /comments endpoints were sized for a
problem the data shape doesn't have: prod p99 is ~30 comments per issue
and the all-time max is ~1.1k. Time-based pagination also splits reply
threads across page boundaries (orphan replies), which the frontend was
papering over with an "orphan rescue" that promoted disconnected replies
to top-level — confusing UX with no real benefit.

Replace both endpoints with a single full-issue fetch, capped server-side
at 2000 rows as a defensive safety net (never hit in practice).

Server
- /api/issues/:id/timeline now returns a flat ASC TimelineEntry[]
  (matches the legacy desktop contract — older Multica.app builds keep
  working because the wrapped TimelineResponse + cursors are gone, and
  the raw array shape was always what they consumed).
- /api/issues/:id/comments drops limit/offset; only ?since is honoured
  for the CLI agent-polling flow.
- Drop ListCommentsBefore/After/Latest, ListActivitiesBefore/After/Latest
  and the timelineCursor encoding.
- Replace with ListCommentsForIssue / ListCommentsSinceForIssue /
  ListActivitiesForIssue (capped by argument).

CLI
- multica issue comment list drops --limit / --offset and the X-Total-Count
  reporting; --since is preserved for incremental polling.

Frontend
- Replace useInfiniteQuery with useQuery in useIssueTimeline; drop
  fetchOlder/Newer, jumpToLatest, isAtLatest, newEntriesBelowCount.
- Remove timeline-cache helpers (mapAllEntries / filterAllEntries /
  prependToLatestPage) and the TimelinePage / TimelinePageParam types.
- WS event handlers update the single flat-array cache directly.
- Drop the orphan-reply rescue in issue-detail — every reply's parent
  is now guaranteed to be in the same array.
- Strip the "show older / show newer / jump to latest" buttons and their
  i18n strings.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 15:46:33 +08:00
26 changed files with 455 additions and 2304 deletions

View File

@@ -43,8 +43,7 @@ import type {
RuntimeLocalSkillListRequest,
CreateRuntimeLocalSkillImportRequest,
RuntimeLocalSkillImportRequest,
TimelinePage,
TimelinePageParam,
TimelineEntry,
AssigneeFrequencyEntry,
TaskMessagePayload,
Attachment,
@@ -92,10 +91,10 @@ import {
ChildIssuesResponseSchema,
CommentsListSchema,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_TIMELINE_PAGE,
EMPTY_TIMELINE_ENTRIES,
ListIssuesResponseSchema,
SubscribersListSchema,
TimelinePageSchema,
TimelineEntriesSchema,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -517,20 +516,11 @@ export class ApiClient {
});
}
async listTimeline(
issueId: string,
pageParam: TimelinePageParam = { mode: "latest" },
limit = 50,
): Promise<TimelinePage> {
const params = new URLSearchParams();
params.set("limit", String(limit));
if (pageParam.mode === "before") params.set("before", pageParam.cursor);
else if (pageParam.mode === "after") params.set("after", pageParam.cursor);
else if (pageParam.mode === "around") params.set("around", pageParam.id);
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
const raw = await this.fetch<unknown>(
`/api/issues/${issueId}/timeline?${params.toString()}`,
`/api/issues/${issueId}/timeline`,
);
return parseWithFallback(raw, TimelinePageSchema, EMPTY_TIMELINE_PAGE, {
return parseWithFallback(raw, TimelineEntriesSchema, EMPTY_TIMELINE_ENTRIES, {
endpoint: "GET /api/issues/:id/timeline",
});
}

View File

@@ -25,53 +25,34 @@ afterEach(() => {
// an empty/safe shape, never throws into React.
describe("ApiClient schema fallback", () => {
describe("listTimeline", () => {
it("falls back to an empty page when required fields are missing", async () => {
stubFetchJson({});
it("falls back to an empty array when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const page = await client.listTimeline("issue-1");
expect(page).toEqual({
entries: [],
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
});
const entries = await client.listTimeline("issue-1");
expect(entries).toEqual([]);
});
it("falls back when a field has the wrong type", async () => {
stubFetchJson({
entries: "not-an-array",
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
});
it("falls back when the body is not an array", async () => {
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const page = await client.listTimeline("issue-1");
expect(page.entries).toEqual([]);
expect(page.has_more_after).toBe(false);
const entries = await client.listTimeline("issue-1");
expect(entries).toEqual([]);
});
it("accepts a new entry type rather than crashing on enum drift", async () => {
stubFetchJson({
entries: [
{
type: "future_kind", // not in TS union
id: "e-1",
actor_type: "member",
actor_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
},
],
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
});
stubFetchJson([
{
type: "future_kind", // not in TS union
id: "e-1",
actor_type: "member",
actor_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
},
]);
const client = new ApiClient("https://api.example.test");
const page = await client.listTimeline("issue-1");
expect(page.entries).toHaveLength(1);
expect(page.entries[0]?.type).toBe("future_kind");
const entries = await client.listTimeline("issue-1");
expect(entries).toHaveLength(1);
expect(entries[0]?.type).toBe("future_kind");
});
// Forward-compat: when the server adds a new field to an existing
@@ -79,51 +60,21 @@ describe("ApiClient schema fallback", () => {
// zod 4 strips it, which would silently break a future TS type that
// adopts the field — see schemas.ts header comment.
it("preserves unknown fields the schema didn't list", async () => {
stubFetchJson({
entries: [
{
type: "comment",
id: "e-1",
actor_type: "member",
actor_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
// New server-side field not present in TimelineEntrySchema:
future_field: { nested: "value" },
},
],
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
// New top-level field not present in TimelinePageSchema:
page_metadata: { took_ms: 42 },
});
stubFetchJson([
{
type: "comment",
id: "e-1",
actor_type: "member",
actor_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
// New server-side field not present in TimelineEntrySchema:
future_field: { nested: "value" },
},
]);
const client = new ApiClient("https://api.example.test");
const page = await client.listTimeline("issue-1");
const raw = page as unknown as Record<string, unknown>;
const entry = page.entries[0] as unknown as Record<string, unknown>;
const entries = await client.listTimeline("issue-1");
const entry = entries[0] as unknown as Record<string, unknown>;
expect(entry.future_field).toEqual({ nested: "value" });
expect(raw.page_metadata).toEqual({ took_ms: 42 });
});
it("returns an empty page when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const page = await client.listTimeline("issue-1");
expect(page.entries).toEqual([]);
});
it("treats null arrays as empty arrays", async () => {
stubFetchJson({
entries: null,
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
});
const client = new ApiClient("https://api.example.test");
const page = await client.listTimeline("issue-1");
expect(page.entries).toEqual([]);
});
});

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import type { ListIssuesResponse, TimelinePage } from "../types";
import type { ListIssuesResponse, TimelineEntry } from "../types";
// ---------------------------------------------------------------------------
// Schemas for the highest-risk API endpoints — those whose responses drive
@@ -66,22 +66,12 @@ const TimelineEntrySchema = z.object({
coalesced_count: z.number().optional(),
}).loose();
export const TimelinePageSchema = z.object({
entries: z.array(TimelineEntrySchema).default([]),
next_cursor: z.string().nullable().default(null),
prev_cursor: z.string().nullable().default(null),
has_more_before: z.boolean().default(false),
has_more_after: z.boolean().default(false),
target_index: z.number().optional(),
}).loose();
// /timeline returns a flat array of TimelineEntry, oldest first. The
// previously cursor-paginated wrapper was removed (#1929) — at observed data
// sizes (p99 ~30 entries per issue) paged delivery only created bugs.
export const TimelineEntriesSchema = z.array(TimelineEntrySchema);
export const EMPTY_TIMELINE_PAGE: TimelinePage = {
entries: [],
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
};
export const EMPTY_TIMELINE_ENTRIES: TimelineEntry[] = [];
export const CommentSchema = z.object({
id: z.string(),

View File

@@ -23,12 +23,6 @@ import type {
ListIssuesCache,
} from "../types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
import {
mapAllEntries,
filterAllEntries,
prependToLatestPage,
type TimelineCacheData,
} from "./timeline-cache";
// ---------------------------------------------------------------------------
// Shared mutation variable types — used by both mutation hooks and
@@ -303,6 +297,8 @@ export function useBatchDeleteIssues() {
// Comments / Timeline
// ---------------------------------------------------------------------------
type TimelineCache = TimelineEntry[];
export function useCreateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
@@ -318,11 +314,6 @@ export function useCreateComment(issueId: string) {
attachmentIds?: string[];
}) => api.createComment(issueId, content, type, parentId, attachmentIds),
onSuccess: (comment) => {
// Write into every paginated timeline cache that's currently at-latest
// (around-mode caches viewing older windows skip silently inside
// prependToLatestPage). Both the latest cache and any open around-mode
// window that has been scrolled all the way to the live tail get the
// optimistic entry; everything else falls back to invalidation.
const entry: TimelineEntry = {
type: "comment",
id: comment.id,
@@ -336,10 +327,14 @@ export function useCreateComment(issueId: string) {
created_at: comment.created_at,
updated_at: comment.updated_at,
};
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) => prependToLatestPage(old, entry),
);
// Dedupe by id: the `comment:created` WS event may have already added
// this entry from the broadcast path before this onSuccess fires. Skip
// the append if the entry is already in the cache.
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) => {
if (!old) return [entry];
if (old.some((e) => e.id === entry.id)) return old;
return [...old, entry];
});
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
@@ -353,26 +348,16 @@ export function useUpdateComment(issueId: string) {
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
api.updateComment(commentId, content),
onMutate: async ({ commentId, content }) => {
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
// Snapshot every open timeline cache (latest + any around windows) so
// an error rollback restores them all atomically.
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
queryKey: ["issues", "timeline", issueId],
});
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) =>
mapAllEntries(old, (e) =>
e.id === commentId ? { ...e, content } : e,
),
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
);
return { prevSnapshots };
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevSnapshots) {
for (const [key, prev] of ctx.prevSnapshots) {
qc.setQueryData(key, prev);
}
if (ctx?.prev !== undefined) {
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
}
},
onSettled: () => {
@@ -386,44 +371,36 @@ export function useDeleteComment(issueId: string) {
return useMutation({
mutationFn: (commentId: string) => api.deleteComment(commentId),
onMutate: async (commentId) => {
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
queryKey: ["issues", "timeline", issueId],
});
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
// Cascade: collect all child comment IDs across every loaded page.
// Cascade: collect all descendants of the deleted comment.
const toRemove = new Set<string>([commentId]);
for (const [, data] of prevSnapshots) {
if (!data) continue;
if (prev) {
let changed = true;
while (changed) {
changed = false;
for (const page of data.pages) {
for (const e of page.entries) {
if (
e.parent_id &&
toRemove.has(e.parent_id) &&
!toRemove.has(e.id)
) {
toRemove.add(e.id);
changed = true;
}
for (const e of prev) {
if (
e.parent_id &&
toRemove.has(e.parent_id) &&
!toRemove.has(e.id)
) {
toRemove.add(e.id);
changed = true;
}
}
}
}
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) => filterAllEntries(old, (e) => toRemove.has(e.id)),
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
old?.filter((e) => !toRemove.has(e.id)),
);
return { prevSnapshots };
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevSnapshots) {
for (const [key, prev] of ctx.prevSnapshots) {
qc.setQueryData(key, prev);
}
if (ctx?.prev !== undefined) {
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
}
},
onSettled: () => {
@@ -438,31 +415,25 @@ export function useResolveComment(issueId: string) {
mutationFn: ({ commentId, resolved }: { commentId: string; resolved: boolean }) =>
resolved ? api.resolveComment(commentId) : api.unresolveComment(commentId),
onMutate: async ({ commentId, resolved }) => {
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
queryKey: ["issues", "timeline", issueId],
});
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) =>
mapAllEntries(old, (e) =>
e.id === commentId
? {
...e,
resolved_at: resolved ? new Date().toISOString() : null,
resolved_by_type: resolved ? e.resolved_by_type ?? null : null,
resolved_by_id: resolved ? e.resolved_by_id ?? null : null,
}
: e,
),
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
old?.map((e) =>
e.id === commentId
? {
...e,
resolved_at: resolved ? new Date().toISOString() : null,
resolved_by_type: resolved ? e.resolved_by_type ?? null : null,
resolved_by_id: resolved ? e.resolved_by_id ?? null : null,
}
: e,
),
);
return { prevSnapshots };
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevSnapshots) {
for (const [key, prev] of ctx.prevSnapshots) {
qc.setQueryData(key, prev);
}
if (ctx?.prev !== undefined) {
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
}
},
onSettled: () => {

View File

@@ -1,11 +1,9 @@
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type {
IssueStatus,
ListIssuesParams,
ListIssuesCache,
TimelinePage,
TimelinePageParam,
} from "../types";
import { BOARD_STATUSES } from "./config";
@@ -23,15 +21,9 @@ export const issueKeys = {
[...issueKeys.all(wsId), "children", id] as const,
childProgress: (wsId: string) =>
[...issueKeys.all(wsId), "child-progress"] as const,
/**
* Cursor-paginated timeline cache. Around-mode lookups use a separate cache
* (keyed by the anchor id) so an Inbox-jump fetch does not pollute the
* default latest-page cache that the regular issue list path consumes.
*/
timeline: (issueId: string, around?: string | null) =>
around
? (["issues", "timeline", issueId, "around", around] as const)
: (["issues", "timeline", issueId] as const),
/** Full-issue timeline (single TanStack Query, no cursor). */
timeline: (issueId: string) =>
["issues", "timeline", issueId] as const,
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
@@ -141,39 +133,16 @@ export function childIssuesOptions(wsId: string, id: string) {
}
/**
* Infinite-query options for the cursor-paginated timeline. The first page is
* either the latest 50 entries (no `around`) or a 50-wide window centered on
* the given comment/activity id (Inbox jump path). `getNextPageParam` walks
* older; `getPreviousPageParam` walks newer.
* Single-fetch timeline options. The endpoint returns the full ordered set of
* comments + activities for an issue (server caps at 2000 as a safety net).
* Cursor pagination was removed in #1929 — at observed data sizes (p99 ~30
* entries per issue) it added complexity without a UX win and broke reply
* threads at page boundaries.
*/
export function issueTimelineInfiniteOptions(
issueId: string,
around?: string | null,
) {
return infiniteQueryOptions<
TimelinePage,
Error,
{ pages: TimelinePage[]; pageParams: TimelinePageParam[] },
readonly unknown[],
TimelinePageParam
>({
queryKey: issueKeys.timeline(issueId, around ?? null),
initialPageParam: around
? ({ mode: "around", id: around } as TimelinePageParam)
: ({ mode: "latest" } as TimelinePageParam),
queryFn: ({ pageParam }) => api.listTimeline(issueId, pageParam),
// Walk older: append a page below the current oldest (last entry of the
// last loaded page). undefined = no more older entries.
getNextPageParam: (lastPage) =>
lastPage.has_more_before && lastPage.next_cursor
? ({ mode: "before", cursor: lastPage.next_cursor } as TimelinePageParam)
: undefined,
// Walk newer: prepend a page above the current newest (first entry of the
// first loaded page). undefined = at the latest, no newer to fetch.
getPreviousPageParam: (firstPage) =>
firstPage.has_more_after && firstPage.prev_cursor
? ({ mode: "after", cursor: firstPage.prev_cursor } as TimelinePageParam)
: undefined,
export function issueTimelineOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.timeline(issueId),
queryFn: () => api.listTimeline(issueId),
});
}

View File

@@ -1,73 +0,0 @@
import type { InfiniteData } from "@tanstack/react-query";
import type {
TimelineEntry,
TimelinePage,
TimelinePageParam,
} from "../types";
/** Shape of the cursor-paginated timeline cache. Exported so consumers (the
* hook, mutations, tests) all reference the same type. */
export type TimelineCacheData = InfiniteData<TimelinePage, TimelinePageParam>;
/** Map fn over every entry across every page, preserving page identity for
* any page whose entries don't change so React.memo on CommentCard isn't
* defeated by gratuitous reference churn. */
export function mapAllEntries(
data: TimelineCacheData | undefined,
fn: (e: TimelineEntry) => TimelineEntry,
): TimelineCacheData | undefined {
if (!data) return data;
let pagesChanged = false;
const pages = data.pages.map((page) => {
let entriesChanged = false;
const entries = page.entries.map((e) => {
const next = fn(e);
if (next !== e) entriesChanged = true;
return next;
});
if (!entriesChanged) return page;
pagesChanged = true;
return { ...page, entries };
});
if (!pagesChanged) return data;
return { ...data, pages };
}
/** Filter out entries matching the predicate from every page. */
export function filterAllEntries(
data: TimelineCacheData | undefined,
predicate: (e: TimelineEntry) => boolean,
): TimelineCacheData | undefined {
if (!data) return data;
let pagesChanged = false;
const pages = data.pages.map((page) => {
const entries = page.entries.filter((e) => !predicate(e));
if (entries.length === page.entries.length) return page;
pagesChanged = true;
return { ...page, entries };
});
if (!pagesChanged) return data;
return { ...data, pages };
}
/** Prepend a new entry to the latest page (pages[0]). Caller must verify
* the cache is at-latest before calling — otherwise the entry is hidden
* behind a "show newer" gap and shouldn't be injected. Returns the data
* unchanged if the cache is not at-latest or the entry already exists. */
export function prependToLatestPage(
data: TimelineCacheData | undefined,
entry: TimelineEntry,
): TimelineCacheData | undefined {
if (!data || data.pages.length === 0) return data;
const first = data.pages[0];
if (!first) return data;
if (first.has_more_after) return data; // not at latest; skip silently
if (first.entries.some((e) => e.id === entry.id)) return data;
return {
...data,
pages: [
{ ...first, entries: [entry, ...first.entries] },
...data.pages.slice(1),
],
};
}

View File

@@ -25,7 +25,6 @@
"./issues": "./issues/index.ts",
"./issues/queries": "./issues/queries.ts",
"./issues/mutations": "./issues/mutations.ts",
"./issues/timeline-cache": "./issues/timeline-cache.ts",
"./issues/ws-updaters": "./issues/ws-updaters.ts",
"./issues/config": "./issues/config/index.ts",
"./issues/config/status": "./issues/config/status.ts",

View File

@@ -30,23 +30,3 @@ export interface TimelineEntry {
coalesced_count?: number;
}
/**
* Cursor-paginated timeline page. Entries are newest-first
* (created_at DESC, id DESC). Cursors are opaque base64 strings — pass them
* back unchanged via TimelinePageParam.
*/
export interface TimelinePage {
entries: TimelineEntry[];
next_cursor: string | null;
prev_cursor: string | null;
has_more_before: boolean;
has_more_after: boolean;
/** Set only in around-id mode; index of the anchor entry within `entries`. */
target_index?: number;
}
export type TimelinePageParam =
| { mode: "latest" }
| { mode: "before"; cursor: string }
| { mode: "after"; cursor: string }
| { mode: "around"; id: string };

View File

@@ -45,8 +45,6 @@ export type { Comment, CommentType, CommentAuthorType, Reaction } from "./commen
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
export type {
TimelineEntry,
TimelinePage,
TimelinePageParam,
AssigneeFrequencyEntry,
} from "./activity";
export type { IssueSubscriber } from "./subscriber";

View File

@@ -179,13 +179,7 @@ vi.mock("../../projects/components/project-picker", () => ({
// Mock api
const mockApiObj = vi.hoisted(() => ({
getIssue: vi.fn(),
listTimeline: vi.fn().mockResolvedValue({
entries: [],
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
}),
listTimeline: vi.fn().mockResolvedValue([]),
listComments: vi.fn().mockResolvedValue([]),
createComment: vi.fn(),
updateComment: vi.fn(),
@@ -391,18 +385,8 @@ describe("IssueDetail (shared)", () => {
mockViewport.isMobile = false;
// Default: issue loads successfully
mockApiObj.getIssue.mockResolvedValue(mockIssue);
// Cursor-paginated timeline endpoint returns a TimelinePage. The DESC
// order is required because the hook reverses pages → ASC for the UI.
const descTimeline = [...mockTimeline].sort((a, b) =>
b.created_at.localeCompare(a.created_at),
);
mockApiObj.listTimeline.mockResolvedValue({
entries: descTimeline,
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
});
// /timeline returns the entries flat in chronological order (oldest first).
mockApiObj.listTimeline.mockResolvedValue(mockTimeline);
mockApiObj.listIssueReactions.mockResolvedValue([]);
mockApiObj.listIssueSubscribers.mockResolvedValue([]);
mockApiObj.listChildIssues.mockResolvedValue({ issues: [] });
@@ -526,43 +510,6 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByText("I can help with this")).toBeInTheDocument();
});
// Orphan-reply rescue (#1857): a reply whose parent is paginated out of the
// current page used to disappear from the UI entirely, since only the
// root's CommentCard knew to pull replies from repliesByParent. Now the
// reply is promoted to top-level and rendered standalone, so the user
// never loses sight of comment content even when the page boundary cuts
// through a thread.
it("renders orphaned replies (parent not in timeline) at top level", async () => {
mockApiObj.listTimeline.mockResolvedValue({
entries: [
{
type: "comment",
id: "reply-1",
actor_type: "member",
actor_id: "user-1",
// parent_id refers to a comment that is NOT in this page (would
// happen if the merge truncation drops the root or pagination
// splits the thread).
parent_id: "missing-parent",
content: "Reply with no visible parent",
created_at: "2026-01-18T00:00:00Z",
updated_at: "2026-01-18T00:00:00Z",
comment_type: "comment",
},
],
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
});
renderIssueDetail();
await waitFor(() => {
expect(screen.getByText("Reply with no visible parent")).toBeInTheDocument();
});
});
it("sends empty description when editor is cleared", async () => {
renderIssueDetail();

View File

@@ -6,12 +6,10 @@ import { AppLink } from "../../navigation";
import { useNavigation } from "../../navigation";
import {
Archive,
ArrowDownToLine,
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
CircleCheck,
MoreHorizontal,
PanelRight,
@@ -302,11 +300,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
timeline, loading: timelineLoading,
submitComment, submitReply,
editComment, deleteComment, toggleResolveComment, toggleReaction: handleToggleReaction,
hasMoreOlder, hasMoreNewer,
isFetchingOlder, isFetchingNewer,
fetchOlder, fetchNewer, jumpToLatest,
isAtLatest, newEntriesBelowCount,
} = useIssueTimeline(id, user?.id, { around: highlightCommentId ?? null });
} = useIssueTimeline(id, user?.id);
// Resolve / unresolve must always clear the per-session expand entry so
// re-resolving an already-expanded thread folds it back to the bar (the
@@ -326,22 +320,16 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
// CommentCard can skip re-rendering when the only thing that moved was
// unrelated parent state (e.g. composer draft, sidebar toggle).
const timelineView = useMemo(() => {
// Orphan-reply rescue (#1857): a reply whose parent_id points to a
// comment that isn't in the loaded timeline gets promoted to top-level
// instead of disappearing. Without this, paginating between a root and
// its replies (or a backend bug that drops the root from the page) hides
// the entire reply subtree because only the root's CommentCard knows to
// pull its children out of repliesByParent.
const idsInTimeline = new Set(timeline.map((e) => e.id));
// Group entries: top-level = activities + root comments; replies are
// bucketed under their parent's id and rendered nested inside CommentCard.
// No orphan rescue needed: the timeline is fetched in full, so every
// reply's parent is always in the same array.
const topLevel = timeline.filter(
(e) =>
e.type === "activity" ||
!e.parent_id ||
!idsInTimeline.has(e.parent_id),
(e) => e.type === "activity" || !e.parent_id,
);
const repliesByParent = new Map<string, TimelineEntry[]>();
for (const e of timeline) {
if (e.type === "comment" && e.parent_id && idsInTimeline.has(e.parent_id)) {
if (e.type === "comment" && e.parent_id) {
const list = repliesByParent.get(e.parent_id) ?? [];
list.push(e);
repliesByParent.set(e.parent_id, list);
@@ -1040,21 +1028,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
<TimelineSkeleton />
) : (
<>
{hasMoreOlder && (
<div className="my-4 flex justify-center">
<Button
variant="outline"
size="sm"
onClick={fetchOlder}
disabled={isFetchingOlder}
>
<ChevronUp />
{isFetchingOlder
? t(($) => $.timeline.loading)
: t(($) => $.timeline.show_older)}
</Button>
</div>
)}
<div className="mt-4 flex flex-col gap-3">
{timelineView.groups.map((group) => {
if (group.type === "comment") {
@@ -1149,37 +1122,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
);
})}
</div>
{(hasMoreNewer || !isAtLatest) && (
<div className="mt-4 flex items-center justify-center gap-2">
{hasMoreNewer && (
<Button
variant="outline"
size="sm"
onClick={fetchNewer}
disabled={isFetchingNewer}
>
<ChevronDown />
{isFetchingNewer
? t(($) => $.timeline.loading)
: t(($) => $.timeline.show_newer)}
</Button>
)}
{!isAtLatest && (
<Button
variant="default"
size="sm"
onClick={jumpToLatest}
>
<ArrowDownToLine />
{newEntriesBelowCount > 0
? t(($) => $.timeline.jump_to_latest_with_count, {
count: newEntriesBelowCount,
})
: t(($) => $.timeline.jump_to_latest)}
</Button>
)}
</div>
)}
</>
)}

View File

@@ -47,49 +47,28 @@ vi.mock("@multica/core/issues/mutations", () => ({
}),
}));
// Spy on issueTimelineInfiniteOptions so tests can assert which `around` value
// the hook actually fed to useInfiniteQuery on each render.
const queriesMock = vi.hoisted(() => ({
issueTimelineInfiniteOptions: vi.fn(
(id: string, around?: string | null) => ({
queryKey: around
? ["issues", "timeline", id, "around", around]
: ["issues", "timeline", id],
queryFn: () => Promise.resolve({}),
initialPageParam: { mode: "latest" as const },
getNextPageParam: () => undefined,
getPreviousPageParam: () => undefined,
}),
),
}));
vi.mock("@multica/core/issues/queries", () => ({
issueTimelineInfiniteOptions: queriesMock.issueTimelineInfiniteOptions,
issueTimelineOptions: (id: string) => ({
queryKey: ["issues", "timeline", id],
queryFn: () => Promise.resolve([]),
}),
issueKeys: {
timeline: (id: string, around?: string | null) =>
around
? ["issues", "timeline", id, "around", around]
: ["issues", "timeline", id],
timeline: (id: string) => ["issues", "timeline", id],
},
}));
// Hoisted state controllable from tests — represents what useInfiniteQuery
// would return for the current render.
// Hoisted state controllable from tests — represents what useQuery would
// return for the current render.
const queryState = vi.hoisted(() => ({
// by default: at-latest with one page that has no newer entries.
data: undefined as unknown,
isLoading: false,
}));
function emptyPage() {
return {
entries: [],
next_cursor: null,
prev_cursor: null,
has_more_before: false,
has_more_after: false,
};
}
// Track the latest cache-update fn the hook hands to setQueryData so tests
// can assert what would have been written.
const cacheUpdates = vi.hoisted(() => ({
last: null as unknown,
}));
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
@@ -97,22 +76,18 @@ vi.mock("@tanstack/react-query", async () => {
);
return {
...actual,
useInfiniteQuery: () => ({
useQuery: () => ({
data: queryState.data,
isLoading: queryState.isLoading,
fetchNextPage: vi.fn(),
fetchPreviousPage: vi.fn(),
hasNextPage: false,
hasPreviousPage: false,
isFetchingNextPage: false,
isFetchingPreviousPage: false,
}),
useQueryClient: () => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
setQueriesData: vi.fn(),
setQueryData: vi.fn((_key: unknown, updater: unknown) => {
cacheUpdates.last = typeof updater === "function"
? (updater as (old: unknown) => unknown)(queryState.data)
: updater;
}),
getQueryData: vi.fn(),
getQueriesData: vi.fn(() => []),
cancelQueries: vi.fn(),
}),
useMutationState: () => [],
@@ -135,12 +110,9 @@ import { useIssueTimeline } from "./use-issue-timeline";
describe("useIssueTimeline", () => {
beforeEach(() => {
wsHandlers.clear();
queriesMock.issueTimelineInfiniteOptions.mockClear();
queryState.data = {
pages: [{ ...emptyPage(), has_more_after: false }],
pageParams: [{ mode: "latest" }],
};
queryState.data = [];
queryState.isLoading = false;
cacheUpdates.last = null;
});
// CommentCard is wrapped in React.memo (perf fix for long timelines, see
@@ -170,84 +142,19 @@ describe("useIssueTimeline", () => {
expect(result.current.submitComment).toBe(first.submitComment);
});
it("flattens DESC pages into ASC timeline order", () => {
queryState.data = {
pages: [
// Latest page: DESC.
{
...emptyPage(),
entries: [
{ type: "comment", id: "c3", actor_type: "member", actor_id: "u", created_at: "2026-05-06T03:00:00Z" },
{ type: "comment", id: "c2", actor_type: "member", actor_id: "u", created_at: "2026-05-06T02:00:00Z" },
],
has_more_after: false,
},
// Older page: also DESC.
{
...emptyPage(),
entries: [
{ type: "comment", id: "c1", actor_type: "member", actor_id: "u", created_at: "2026-05-06T01:00:00Z" },
],
},
],
pageParams: [{ mode: "latest" }, { mode: "before", cursor: "x" }],
};
it("returns the timeline as a flat array directly from the query cache", () => {
queryState.data = [
{ type: "comment", id: "c1", actor_type: "member", actor_id: "u", created_at: "2026-05-06T01:00:00Z" },
{ type: "comment", id: "c2", actor_type: "member", actor_id: "u", created_at: "2026-05-06T02:00:00Z" },
{ type: "comment", id: "c3", actor_type: "member", actor_id: "u", created_at: "2026-05-06T03:00:00Z" },
];
const { result } = renderHook(() => useIssueTimeline("issue-1", "user-1"));
const ids = result.current.timeline.map((e) => e.id);
// ASC: oldest at top, newest at bottom.
expect(ids).toEqual(["c1", "c2", "c3"]);
expect(result.current.timeline.map((e) => e.id)).toEqual(["c1", "c2", "c3"]);
});
it("reports isAtLatest=true when first page has no newer entries", () => {
queryState.data = {
pages: [{ ...emptyPage(), has_more_after: false }],
pageParams: [{ mode: "latest" }],
};
const { result } = renderHook(() => useIssueTimeline("issue-1", "user-1"));
expect(result.current.isAtLatest).toBe(true);
expect(result.current.newEntriesBelowCount).toBe(0);
});
it("bumps newEntriesBelowCount when comment:created arrives while not at latest", () => {
// Around-mode page: the user is reading older history, so has_more_after=true.
queryState.data = {
pages: [{ ...emptyPage(), has_more_after: true }],
pageParams: [{ mode: "around", id: "anchor" }],
};
const { result } = renderHook(() =>
useIssueTimeline("issue-1", "user-1", { around: "anchor" }),
);
expect(result.current.isAtLatest).toBe(false);
expect(result.current.newEntriesBelowCount).toBe(0);
const handler = wsHandlers.get("comment:created");
expect(handler).toBeDefined();
act(() => {
handler!({
comment: {
id: "new-c",
issue_id: "issue-1",
author_type: "member",
author_id: "u",
content: "hi",
parent_id: null,
created_at: "2026-05-06T05:00:00Z",
updated_at: "2026-05-06T05:00:00Z",
type: "comment",
reactions: [],
attachments: [],
},
});
});
expect(result.current.newEntriesBelowCount).toBe(1);
});
it("does NOT bump newEntriesBelowCount when at-latest (entry should land in cache instead)", () => {
queryState.data = {
pages: [{ ...emptyPage(), has_more_after: false }],
pageParams: [{ mode: "latest" }],
};
const { result } = renderHook(() => useIssueTimeline("issue-1", "user-1"));
it("comment:created appends the new entry to the cache", () => {
queryState.data = [];
renderHook(() => useIssueTimeline("issue-1", "user-1"));
const handler = wsHandlers.get("comment:created");
act(() => {
handler!({
@@ -266,17 +173,13 @@ describe("useIssueTimeline", () => {
},
});
});
expect(result.current.newEntriesBelowCount).toBe(0);
const updated = cacheUpdates.last as Array<{ id: string }>;
expect(updated.map((e) => e.id)).toEqual(["new-c"]);
});
it("ignores WS events for other issues", () => {
queryState.data = {
pages: [{ ...emptyPage(), has_more_after: true }],
pageParams: [{ mode: "around", id: "anchor" }],
};
const { result } = renderHook(() =>
useIssueTimeline("issue-1", "user-1", { around: "anchor" }),
);
queryState.data = [];
renderHook(() => useIssueTimeline("issue-1", "user-1"));
const handler = wsHandlers.get("comment:created");
act(() => {
handler!({
@@ -295,65 +198,7 @@ describe("useIssueTimeline", () => {
},
});
});
expect(result.current.newEntriesBelowCount).toBe(0);
});
// Regression: when the inbox split-pane is open and the user clicks a
// comment-notification (around=<id>) and then a notification for the SAME
// issue without a comment_id (around=undefined), <IssueDetail> stays
// mounted (keyed on issueId, see inbox-page.tsx). The hook therefore has
// to react to the around prop transitioning back to falsy — otherwise the
// around-mode cache is re-served on every subsequent click and entries
// outside the original window appear "lost" until a hard refresh.
it("syncs around state when the prop transitions back to undefined", () => {
const { rerender } = renderHook(
({ around }: { around?: string | null }) =>
useIssueTimeline("issue-1", "user-1", { around }),
{ initialProps: { around: "comment-X" as string | null | undefined } },
);
// First render is anchored on comment-X.
const firstAround = queriesMock.issueTimelineInfiniteOptions.mock.calls.at(-1)?.[1];
expect(firstAround).toBe("comment-X");
rerender({ around: undefined });
// After transitioning to undefined, the next call MUST use the latest
// (around=null) cache, not keep re-using the comment-X anchor.
const lastAround = queriesMock.issueTimelineInfiniteOptions.mock.calls.at(-1)?.[1];
expect(lastAround).toBeNull();
});
it("jumpToLatest clears newEntriesBelowCount", () => {
queryState.data = {
pages: [{ ...emptyPage(), has_more_after: true }],
pageParams: [{ mode: "around", id: "anchor" }],
};
const { result } = renderHook(() =>
useIssueTimeline("issue-1", "user-1", { around: "anchor" }),
);
const handler = wsHandlers.get("comment:created");
act(() => {
handler!({
comment: {
id: "n",
issue_id: "issue-1",
author_type: "member",
author_id: "u",
content: "",
parent_id: null,
created_at: "",
updated_at: "",
type: "comment",
reactions: [],
attachments: [],
},
});
});
expect(result.current.newEntriesBelowCount).toBe(1);
act(() => {
result.current.jumpToLatest();
});
expect(result.current.newEntriesBelowCount).toBe(0);
// setQueryData should not have been invoked for a non-matching issue.
expect(cacheUpdates.last).toBeNull();
});
});

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import {
useInfiniteQuery,
useQuery,
useQueryClient,
useMutationState,
} from "@tanstack/react-query";
@@ -20,15 +20,9 @@ import type {
ReactionRemovedPayload,
} from "@multica/core/types";
import {
issueTimelineInfiniteOptions,
issueTimelineOptions,
issueKeys,
} from "@multica/core/issues/queries";
import {
mapAllEntries,
filterAllEntries,
prependToLatestPage,
type TimelineCacheData,
} from "@multica/core/issues/timeline-cache";
import {
useCreateComment,
useUpdateComment,
@@ -41,7 +35,7 @@ import { useWSEvent, useWSReconnect } from "@multica/core/realtime";
import { toast } from "sonner";
import { useT } from "../../i18n";
type TLData = TimelineCacheData;
type TLCache = TimelineEntry[];
function commentToTimelineEntry(c: Comment): TimelineEntry {
return {
@@ -59,61 +53,16 @@ function commentToTimelineEntry(c: Comment): TimelineEntry {
};
}
export interface UseIssueTimelineOptions {
/** Anchor the initial fetch on this entry id (Inbox jump path). When set,
* the first page is centered on the target instead of the latest 50. */
around?: string | null;
}
export function useIssueTimeline(
issueId: string,
userId?: string,
options: UseIssueTimelineOptions = {},
) {
export function useIssueTimeline(issueId: string, userId?: string) {
const { t } = useT("issues");
const qc = useQueryClient();
// Internal anchor state. Starts as the caller's around prop; jumpToLatest
// clears it. A new around prop (e.g. user clicks a different inbox item)
// resets it via the effect below — including transitions back to null when
// the caller switches to a notification without comment_id, otherwise the
// cached around-page is re-served forever after the first inbox jump.
const [around, setAround] = useState<string | null>(options.around ?? null);
useEffect(() => {
setAround(options.around ?? null);
}, [options.around]);
const query = useQuery(issueTimelineOptions(issueId));
const { data, isLoading: loading } = query;
const query = useInfiniteQuery(issueTimelineInfiniteOptions(issueId, around));
const {
data,
isLoading: loading,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
} = query;
// isAtLatest is the cache-invariant we use to decide where WS-delivered
// entries belong. It's true when the FIRST loaded page reports no newer
// entries on the server — i.e. the user is looking at the live tail.
const isAtLatest = data?.pages[0]?.has_more_after === false;
const timeline = useMemo<TimelineEntry[]>(() => data ?? [], [data]);
const [submitting, setSubmitting] = useState(false);
const [newEntriesBelowCount, setNewEntriesBelowCount] = useState(0);
// Flatten pages → ASC array for the legacy UI consumer. pages are DESC
// newest-first; the consumer (issue-detail.tsx) renders chronologically
// (oldest at top). Concat → DESC; reverse once at the end → ASC.
const timeline = useMemo<TimelineEntry[]>(() => {
if (!data) return [];
const flat: TimelineEntry[] = [];
for (const page of data.pages) {
for (const entry of page.entries) flat.push(entry);
}
return flat.reverse();
}, [data]);
// Stable mutation handles. TanStack v5 returns a fresh result wrapper from
// useMutation per render, but the inner mutateAsync / mutate functions are
@@ -126,13 +75,12 @@ export function useIssueTimeline(
const { mutateAsync: resolveCommentAsync } = useResolveComment(issueId);
const { mutate: toggleCommentReaction } = useToggleCommentReaction(issueId);
// Reconnect recovery: drop the cache so the next render refetches the
// latest page from scratch. We don't try to reconcile diffs over a
// possibly-long disconnect — easier to start fresh.
// Reconnect recovery: invalidate so the next render refetches the full
// timeline. Cheaper than diffing across a possibly-long disconnect.
useWSReconnect(
useCallback(() => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId, around) });
}, [qc, issueId, around]),
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
}, [qc, issueId]),
);
// --- WS event handlers ---
@@ -143,17 +91,14 @@ export function useIssueTimeline(
(payload: unknown) => {
const { comment } = payload as CommentCreatedPayload;
if (comment.issue_id !== issueId) return;
if (isAtLatest) {
qc.setQueryData<TLData>(issueKeys.timeline(issueId, around), (old: TLData | undefined) =>
prependToLatestPage(old, commentToTimelineEntry(comment)),
);
} else {
// Reading older history — don't yank scroll position. Surface a
// counter so the UI can offer "jump to latest (N new)".
setNewEntriesBelowCount((c) => c + 1);
}
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) => {
const entry = commentToTimelineEntry(comment);
if (!old) return [entry];
if (old.some((e) => e.id === comment.id)) return old;
return [...old, entry];
});
},
[qc, issueId, around, isAtLatest],
[qc, issueId],
),
);
@@ -163,13 +108,13 @@ export function useIssueTimeline(
(payload: unknown) => {
const { comment } = payload as CommentUpdatedPayload;
if (comment.issue_id !== issueId) return;
qc.setQueryData<TLData>(issueKeys.timeline(issueId, around), (old: TLData | undefined) =>
mapAllEntries(old, (e) =>
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
old?.map((e) =>
e.id === comment.id ? commentToTimelineEntry(comment) : e,
),
);
},
[qc, issueId, around],
[qc, issueId],
),
);
@@ -179,31 +124,29 @@ export function useIssueTimeline(
(payload: unknown) => {
const { comment_id, issue_id } = payload as CommentDeletedPayload;
if (issue_id !== issueId) return;
// Cascade through replies. Walk pages collectively; a reply may live
// on a different page than its parent.
qc.setQueryData<TLData>(issueKeys.timeline(issueId, around), (old: TLData | undefined) => {
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) => {
if (!old) return old;
// Cascade through replies (full timeline now lives in this single
// cache, so a flat sweep is sufficient).
const idsToRemove = new Set<string>([comment_id]);
let changed = true;
while (changed) {
changed = false;
for (const page of old.pages) {
for (const e of page.entries) {
if (
e.parent_id &&
idsToRemove.has(e.parent_id) &&
!idsToRemove.has(e.id)
) {
idsToRemove.add(e.id);
changed = true;
}
for (const e of old) {
if (
e.parent_id &&
idsToRemove.has(e.parent_id) &&
!idsToRemove.has(e.id)
) {
idsToRemove.add(e.id);
changed = true;
}
}
}
return filterAllEntries(old, (e) => idsToRemove.has(e.id));
return old.filter((e) => !idsToRemove.has(e.id));
});
},
[qc, issueId, around],
[qc, issueId],
),
);
@@ -215,15 +158,13 @@ export function useIssueTimeline(
if (p.issue_id !== issueId) return;
const entry = p.entry;
if (!entry || !entry.id) return;
if (isAtLatest) {
qc.setQueryData<TLData>(issueKeys.timeline(issueId, around), (old: TLData | undefined) =>
prependToLatestPage(old, entry),
);
} else {
setNewEntriesBelowCount((c) => c + 1);
}
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) => {
if (!old) return [entry];
if (old.some((e) => e.id === entry.id)) return old;
return [...old, entry];
});
},
[qc, issueId, around, isAtLatest],
[qc, issueId],
),
);
@@ -233,8 +174,8 @@ export function useIssueTimeline(
(payload: unknown) => {
const { reaction, issue_id } = payload as ReactionAddedPayload;
if (issue_id !== issueId) return;
qc.setQueryData<TLData>(issueKeys.timeline(issueId, around), (old: TLData | undefined) =>
mapAllEntries(old, (e) => {
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
old?.map((e) => {
if (e.id !== reaction.comment_id) return e;
const existing = e.reactions ?? [];
if (existing.some((r) => r.id === reaction.id)) return e;
@@ -242,7 +183,7 @@ export function useIssueTimeline(
}),
);
},
[qc, issueId, around],
[qc, issueId],
),
);
@@ -252,8 +193,8 @@ export function useIssueTimeline(
(payload: unknown) => {
const p = payload as ReactionRemovedPayload;
if (p.issue_id !== issueId) return;
qc.setQueryData<TLData>(issueKeys.timeline(issueId, around), (old: TLData | undefined) =>
mapAllEntries(old, (e) => {
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
old?.map((e) => {
if (e.id !== p.comment_id) return e;
return {
...e,
@@ -269,28 +210,10 @@ export function useIssueTimeline(
}),
);
},
[qc, issueId, around],
[qc, issueId],
),
);
// --- Page navigation ---
const fetchOlder = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const fetchNewer = useCallback(() => {
if (hasPreviousPage && !isFetchingPreviousPage) fetchPreviousPage();
}, [hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage]);
const jumpToLatest = useCallback(() => {
// Drop any anchor + prefetched windows. The latest cache (around=null)
// may be cold; an invalidate forces a fresh fetch on next render.
setAround(null);
setNewEntriesBelowCount(0);
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId, null) });
}, [qc, issueId]);
// --- Mutation functions ---
const submitComment = useCallback(
@@ -439,19 +362,6 @@ export function useIssueTimeline(
[userId, toggleCommentReaction],
);
// Around-mode anchor index (target_index from server, applied within the
// first page). Translated to a flat-array index: the array is reversed
// (DESC pages → ASC flat), so the offset within page[0] becomes
// (totalEntries - 1) - target_index.
const targetFlatIndex = useMemo(() => {
if (!data || data.pages.length === 0) return null;
const first = data.pages[0];
if (!first || first.target_index == null) return null;
let total = 0;
for (const p of data.pages) total += p.entries.length;
return total - 1 - first.target_index;
}, [data]);
return {
timeline: optimisticTimeline,
loading,
@@ -462,16 +372,5 @@ export function useIssueTimeline(
deleteComment,
toggleResolveComment,
toggleReaction,
// Pagination controls (new)
hasMoreOlder: hasNextPage,
hasMoreNewer: hasPreviousPage,
isFetchingOlder: isFetchingNextPage,
isFetchingNewer: isFetchingPreviousPage,
fetchOlder,
fetchNewer,
jumpToLatest,
isAtLatest: isAtLatest === true,
newEntriesBelowCount,
targetFlatIndex,
};
}

View File

@@ -134,11 +134,7 @@
"workdir_path_unavailable": "No local workdir yet — issue has not been run by a local agent"
},
"timeline": {
"show_older": "Show older",
"show_newer": "Show newer",
"loading": "Loading...",
"jump_to_latest": "Jump to latest",
"jump_to_latest_with_count": "Jump to latest · {{count}} new"
"loading": "Loading..."
},
"activity": {
"created": "created this issue",

View File

@@ -133,11 +133,7 @@
"workdir_path_unavailable": "暂无本地 workdir — 这个 issue 还没被本地 agent 运行过"
},
"timeline": {
"show_older": "显示更早",
"show_newer": "显示更晚",
"loading": "加载中…",
"jump_to_latest": "跳到最新",
"jump_to_latest_with_count": "跳到最新 · {{count}} 条新动态"
"loading": "加载中…"
},
"activity": {
"created": "创建了这个 issue",

View File

@@ -256,8 +256,6 @@ func init() {
// issue comment list
issueCommentListCmd.Flags().String("output", "table", "Output format: table or json")
issueCommentListCmd.Flags().Int("limit", 50, "Maximum number of comments to return (0 = server default of 50, max ~100 depending on server)")
issueCommentListCmd.Flags().Int("offset", 0, "Number of comments to skip")
issueCommentListCmd.Flags().String("since", "", "Only return comments created after this timestamp (RFC3339)")
// issue runs
@@ -827,12 +825,6 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error {
}
params := url.Values{}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
params.Set("limit", fmt.Sprintf("%d", v))
}
if v, _ := cmd.Flags().GetInt("offset"); v > 0 {
params.Set("offset", fmt.Sprintf("%d", v))
}
if v, _ := cmd.Flags().GetString("since"); v != "" {
params.Set("since", v)
}
@@ -843,20 +835,10 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error {
}
var comments []map[string]any
isPaginated := len(params) > 0
if isPaginated {
headers, getErr := client.GetJSONWithHeaders(ctx, path, &comments)
if getErr != nil {
return fmt.Errorf("list comments: %w", getErr)
}
if total := headers.Get("X-Total-Count"); total != "" {
fmt.Fprintf(os.Stderr, "Showing %d of %s comments.\n", len(comments), total)
}
} else {
if err := client.GetJSON(ctx, path, &comments); err != nil {
return fmt.Errorf("list comments: %w", err)
}
if err := client.GetJSON(ctx, path, &comments); err != nil {
return fmt.Errorf("list comments: %w", err)
}
fmt.Fprintf(os.Stderr, "Showing %d comments.\n", len(comments))
output, _ := cmd.Flags().GetString("output")
if output == "json" {

View File

@@ -13,17 +13,15 @@ import (
)
// listActivitiesForIssue is a test helper that fetches up to 100 activity_log
// records for an issue using the keyset query that backs the cursor-paginated
// timeline endpoint. The unbounded ListActivities was removed when the
// timeline switched to cursor pagination (#1968 root fix).
// records for an issue. Uses the same query that backs the timeline endpoint.
func listActivitiesForIssue(t *testing.T, queries *db.Queries, issueID string) []db.ActivityLog {
t.Helper()
activities, err := queries.ListActivitiesLatest(context.Background(), db.ListActivitiesLatestParams{
activities, err := queries.ListActivitiesForIssue(context.Background(), db.ListActivitiesForIssueParams{
IssueID: util.MustParseUUID(issueID),
Limit: 100,
})
if err != nil {
t.Fatalf("ListActivitiesLatest: %v", err)
t.Fatalf("ListActivitiesForIssue: %v", err)
}
return activities
}
@@ -158,7 +156,7 @@ func TestActivityIssueUpdated_AssigneeChanged(t *testing.T) {
AssigneeType: &assigneeType,
AssigneeID: &assigneeID,
},
"assignee_changed": true,
"assignee_changed": true,
"prev_assignee_type": (*string)(nil),
"prev_assignee_id": (*string)(nil),
},

View File

@@ -106,7 +106,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("### Read\n")
b.WriteString("- `multica issue get <id> --output json` — Get full issue details (title, description, status, priority, assignee)\n")
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X | --assignee-id <uuid>] [--limit N] [--offset N] [--full-id] [--output json]` — List issues in workspace (default limit: 50; table output uses routable issue keys; JSON output includes `total`, `has_more` — use offset to paginate when `has_more` is true). Prefer `--assignee-id <uuid>` when scripting from `multica workspace members --output json` / `multica agent list --output json`.\n")
b.WriteString("- `multica issue comment list <issue-id> [--limit N] [--offset N] [--since <RFC3339>] --output json` — List comments on an issue (supports pagination; includes id, parent_id for threading)\n")
b.WriteString("- `multica issue comment list <issue-id> [--since <RFC3339>] --output json` — List all comments on an issue (server caps at 2000 rows). Use `--since` for incremental polling.\n")
b.WriteString("- `multica issue label list <issue-id> --output json` — List labels currently attached to an issue\n")
b.WriteString("- `multica issue subscriber list <issue-id> --output json` — List members/agents subscribed to an issue\n")
b.WriteString("- `multica label list --output json` — List all labels defined in the workspace (returns id + name + color)\n")
@@ -246,8 +246,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
// Comment-triggered: focus on reading and replying
b.WriteString("**This task was triggered by a NEW comment.** Your primary job is to respond to THIS specific comment, even if you have handled similar requests before in this session.\n\n")
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand the issue context\n", ctx.IssueID)
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation\n", ctx.IssueID)
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation (returns all comments, capped server-side at 2000)\n", ctx.IssueID)
b.WriteString(" - For incremental polling, use `--since <RFC3339-timestamp>` to fetch only comments newer than a known cursor\n")
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked — do NOT confuse it with previous comments\n", ctx.TriggerCommentID)
b.WriteString("4. **Decide whether a reply is warranted.** If you produced actual work this turn (investigated, fixed, answered a real question), post the result via step 6 — that is a normal reply, not a noise comment. If the triggering comment was a pure acknowledgment / thanks / sign-off from another agent AND you produced no work this turn, do NOT post a reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is a valid and preferred way to end agent-to-agent conversations.\n")
b.WriteString("5. If a reply IS warranted: do any requested work first, then **decide whether to include any `@mention` link.** The default is NO mention. Only mention when you are escalating to a human owner who is not yet involved, delegating a concrete new sub-task to another agent for the first time, or the user explicitly asked you to loop someone in. Never @mention the agent you are replying to as a thank-you or sign-off.\n")
@@ -258,8 +258,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
// Assignment-triggered: defer to agent Skills for workflow specifics.
b.WriteString("You are responsible for managing the issue status throughout your work.\n\n")
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand your task\n", ctx.IssueID)
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the full comment history — this is mandatory, not optional. Earlier comments often carry context the issue body lacks (e.g. which repo to work in, the prior agent's findings, the reason the issue was reassigned to you). Skipping this step is the most common cause of agents acting on stale or incomplete instructions.\n", ctx.IssueID)
fmt.Fprintf(&b, " - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the full comment history (returns all comments, capped server-side at 2000) — this is mandatory, not optional. Earlier comments often carry context the issue body lacks (e.g. which repo to work in, the prior agent's findings, the reason the issue was reassigned to you). Skipping this step is the most common cause of agents acting on stale or incomplete instructions.\n", ctx.IssueID)
fmt.Fprintf(&b, "3. Run `multica issue status %s in_progress`\n", ctx.IssueID)
b.WriteString("4. Follow your Skills and Agent Identity to complete the task (write code, investigate, etc.)\n")
fmt.Fprintf(&b, "5. **Post your final results as a comment — this step is mandatory**: `multica issue comment add %s --content \"...\"`. Your results are only visible to the user if posted via this CLI call; text in your terminal or run logs is NOT delivered.\n", ctx.IssueID)

View File

@@ -27,7 +27,7 @@ func BuildPrompt(task Task) string {
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID)
fmt.Fprintf(&b, "If you need comment history, `multica issue comment list %s` returns the latest 50 by default — pass --limit or --since to scope older windows. Long issues can have thousands of comments; do not fetch everything blindly.\n", task.IssueID)
fmt.Fprintf(&b, "If you need comment history, `multica issue comment list %s --output json` returns all comments for the issue (server caps at 2000). Pass `--since <RFC3339>` to fetch only comments newer than a known cursor.\n", task.IssueID)
return b.String()
}
@@ -116,7 +116,7 @@ func buildCommentPrompt(task Task) string {
}
}
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then decide how to proceed.\n\n", task.IssueID)
fmt.Fprintf(&b, "If you need comment history, `multica issue comment list %s` returns the latest 50 by default — pass --limit or --since to scope older windows. Long issues can have thousands of comments; do not fetch everything blindly.\n\n", task.IssueID)
fmt.Fprintf(&b, "If you need comment history, `multica issue comment list %s --output json` returns all comments for the issue (server caps at 2000). Pass `--since <RFC3339>` to fetch only comments newer than a known cursor.\n\n", task.IssueID)
b.WriteString(execenv.BuildCommentReplyInstructions(task.IssueID, task.TriggerCommentID))
return b.String()
}

View File

@@ -1,16 +1,11 @@
package handler
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -41,560 +36,104 @@ type TimelineEntry struct {
ResolvedByID *string `json:"resolved_by_id,omitempty"`
}
// TimelineResponse wraps the cursor-paginated timeline. Entries are sorted
// newest-first (created_at DESC, id DESC). NextCursor / PrevCursor are opaque
// strings; clients pass them back as ?before= / ?after= without inspection.
// HasMoreBefore indicates more entries older than the last in the page;
// HasMoreAfter indicates more entries newer than the first in the page.
type TimelineResponse struct {
// timelineHardCap bounds the per-issue timeline payload. Sized as a defensive
// safety net, not a UX page window: see commentHardCap in comment.go for the
// data-shape rationale (#1929).
const timelineHardCap = 2000
// timelinePaginatedResponse mirrors the wrapper shape produced by the prior
// cursor-paginated ListTimeline (#2128). It is preserved as a backward-compat
// surface for installed Desktop builds and stale Web bundles between #2128 and
// #1929 that send `?limit=`/`?before=`/`?after=`/`?around=` and parse the
// response with the old TimelinePageSchema (entries + cursors). Cursors are
// always nil and `has_more_*` are always false: the new server returns the
// whole timeline in one shot.
type timelinePaginatedResponse struct {
Entries []TimelineEntry `json:"entries"`
NextCursor *string `json:"next_cursor"`
PrevCursor *string `json:"prev_cursor"`
HasMoreBefore bool `json:"has_more_before"`
HasMoreAfter bool `json:"has_more_after"`
// TargetIndex is set only in ?around=<id> mode, locating the anchor entry
// within Entries so the client can scroll/highlight without searching.
TargetIndex *int `json:"target_index,omitempty"`
TargetIndex *int `json:"target_index,omitempty"`
}
const (
// timelineDefaultLimit governs the per-page COMMENT budget. Activities are
// fetched at the same per-call cap but do not consume the budget (#1857) —
// they decorate the comment stream. Without that split, an issue with
// sparse comments but dense activity (agent runs, status flips) triggered
// "show older" prematurely and felt like comments had vanished.
timelineDefaultLimit = 50
timelineMaxLimit = 100
)
// cursorPos is a single (created_at, id) keyset position. Used per-pool —
// see timelineCursor.
type cursorPos struct {
T pgtype.Timestamptz
ID pgtype.UUID
}
// timelineCursor encodes per-pool keyset positions as opaque base64 JSON.
// Comments and activities walk independently (#1857 follow-up): a single
// shared cursor anchored on the merged-page boundary would let an activity
// older than every visible comment hide all unreturned comments behind it,
// since `ListCommentsBefore(activityCursor)` would skip the in-between rows.
// The format is intentionally hidden from clients so future schema evolution
// can replace the payload without breaking API consumers.
type timelineCursor struct {
CommentT time.Time `json:"ct"`
CommentID string `json:"ci"`
ActivityT time.Time `json:"at"`
ActivityID string `json:"ai"`
}
func encodeTimelineCursor(comment, activity cursorPos) string {
c := timelineCursor{
CommentT: comment.T.Time,
CommentID: uuidToString(comment.ID),
ActivityT: activity.T.Time,
ActivityID: uuidToString(activity.ID),
}
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeTimelineCursor(s string) (comment, activity cursorPos, err error) {
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return cursorPos{}, cursorPos{}, err
}
var c timelineCursor
if err = json.Unmarshal(raw, &c); err != nil {
return cursorPos{}, cursorPos{}, err
}
cid, err := parseUUIDStrict(c.CommentID)
if err != nil {
return cursorPos{}, cursorPos{}, err
}
aid, err := parseUUIDStrict(c.ActivityID)
if err != nil {
return cursorPos{}, cursorPos{}, err
}
return cursorPos{T: pgtype.Timestamptz{Time: c.CommentT, Valid: true}, ID: cid},
cursorPos{T: pgtype.Timestamptz{Time: c.ActivityT, Valid: true}, ID: aid},
nil
}
// commentBoundsDesc returns (oldest, newest) cursor positions from a DESC-
// ordered comment slice. If the slice is empty, returns the supplied carry
// position so the cursor walker keeps advancing the empty pool past
// boundaries the caller already paged through.
func commentBoundsDesc(rows []db.Comment, carry cursorPos) (oldest, newest cursorPos) {
if len(rows) == 0 {
return carry, carry
}
return cursorPos{T: rows[len(rows)-1].CreatedAt, ID: rows[len(rows)-1].ID},
cursorPos{T: rows[0].CreatedAt, ID: rows[0].ID}
}
func commentBoundsAsc(rows []db.Comment, carry cursorPos) (oldest, newest cursorPos) {
if len(rows) == 0 {
return carry, carry
}
return cursorPos{T: rows[0].CreatedAt, ID: rows[0].ID},
cursorPos{T: rows[len(rows)-1].CreatedAt, ID: rows[len(rows)-1].ID}
}
func activityBoundsDesc(rows []db.ActivityLog, carry cursorPos) (oldest, newest cursorPos) {
if len(rows) == 0 {
return carry, carry
}
return cursorPos{T: rows[len(rows)-1].CreatedAt, ID: rows[len(rows)-1].ID},
cursorPos{T: rows[0].CreatedAt, ID: rows[0].ID}
}
func activityBoundsAsc(rows []db.ActivityLog, carry cursorPos) (oldest, newest cursorPos) {
if len(rows) == 0 {
return carry, carry
}
return cursorPos{T: rows[0].CreatedAt, ID: rows[0].ID},
cursorPos{T: rows[len(rows)-1].CreatedAt, ID: rows[len(rows)-1].ID}
}
// parseUUIDStrict mirrors util.ParseUUID but returns a pgtype.UUID directly
// without panicking on bad input. Used for cursor decoding where invalid data
// is a 400, not a 500.
func parseUUIDStrict(s string) (pgtype.UUID, error) {
var u pgtype.UUID
if err := u.Scan(s); err != nil {
return pgtype.UUID{}, err
}
if !u.Valid {
return pgtype.UUID{}, errors.New("invalid uuid")
}
return u, nil
}
// ListTimeline returns a cursor-paginated, newest-first slice of the issue
// timeline (comments + activities merged). The query string accepts at most
// one of: ?before=<cursor>, ?after=<cursor>, ?around=<entry_id>. With none,
// the latest page is returned.
// ListTimeline returns the full issue timeline (comments + activities merged).
// Two response shapes coexist for boundary compatibility (#1929):
//
// - No pagination params → flat ASC `TimelineEntry[]`. Matches the legacy
// desktop contract (Multica.app ≤ v0.2.25) and the new client.
// - Any of `limit` / `before` / `after` / `around` present → wrapped object
// with DESC entries + null cursors + has_more_*=false. Matches what a
// stale v0.2.26+ build expects when it parses the response with
// TimelinePageSchema; cursor-walking is now a no-op so the client just
// sees a single full page.
//
// Both shapes carry the same set of entries — paging and ordering differ.
// Time-based pagination was removed because it split reply threads at page
// boundaries, and at observed data sizes (p99 ~30 comments per issue) the
// cursor machinery was pure overhead.
func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
q := r.URL.Query()
// Backwards-compat: pre-#2128 clients (Multica.app ≤ v0.2.25 and any cached
// web build older than the matching server) call /timeline with no query
// string and consume the response body as TimelineEntry[] directly. The
// new client always sends ?limit=..., so absence of every pagination param
// uniquely identifies a legacy caller. Drop this branch once the desktop
// auto-update has rolled the user base past v0.2.26.
if q.Get("limit") == "" && q.Get("before") == "" &&
q.Get("after") == "" && q.Get("around") == "" {
h.listTimelineLegacy(w, r, issue)
return
}
limit := timelineDefaultLimit
if raw := q.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
if n > timelineMaxLimit {
writeError(w, http.StatusBadRequest, "limit exceeds maximum of 100")
return
}
limit = n
}
before, after, around := q.Get("before"), q.Get("after"), q.Get("around")
modes := 0
for _, s := range []string{before, after, around} {
if s != "" {
modes++
}
}
if modes > 1 {
writeError(w, http.StatusBadRequest, "before, after, and around are mutually exclusive")
return
}
switch {
case around != "":
h.listTimelineAround(w, r, issue, around, limit)
case before != "":
h.listTimelineBefore(w, r, issue, before, limit)
case after != "":
h.listTimelineAfter(w, r, issue, after, limit)
default:
h.listTimelineLatest(w, r, issue, limit)
}
}
// listTimelineLegacy serves clients that predate cursor pagination (#2128) —
// notably Multica.app ≤ v0.2.25, where the renderer reads the response body
// as TimelineEntry[] directly and would crash with "timeline.filter is not a
// function" against the new wrapped shape (#2143, #2147). Returned bounded
// at legacyTimelineCap to honour the spirit of #1968 — old clients couldn't
// render thousands of entries without freezing the tab anyway.
func (h *Handler) listTimelineLegacy(w http.ResponseWriter, r *http.Request, issue db.Issue) {
const legacyTimelineCap = 200
ctx := r.Context()
comments, err := h.Queries.ListCommentsLatest(ctx, db.ListCommentsLatestParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID, Limit: legacyTimelineCap,
comments, err := h.Queries.ListCommentsForIssue(ctx, db.ListCommentsForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Limit: timelineHardCap,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
activities, err := h.Queries.ListActivitiesLatest(ctx, db.ListActivitiesLatestParams{
IssueID: issue.ID, Limit: legacyTimelineCap,
activities, err := h.Queries.ListActivitiesForIssue(ctx, db.ListActivitiesForIssueParams{
IssueID: issue.ID,
Limit: timelineHardCap,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
entries := h.mergeTimelineDesc(r, comments, activities)
if len(entries) > legacyTimelineCap {
entries = entries[:legacyTimelineCap]
q := r.URL.Query()
wantWrapped := q.Get("limit") != "" || q.Get("before") != "" ||
q.Get("after") != "" || q.Get("around") != ""
if wantWrapped {
entries := h.mergeTimeline(r, comments, activities, false)
if entries == nil {
entries = []TimelineEntry{}
}
resp := timelinePaginatedResponse{Entries: entries}
// `around=<id>`: locate the anchor in the DESC slice so the legacy
// client can scroll-to-highlight without a follow-up request.
if anchor := q.Get("around"); anchor != "" {
for i, e := range entries {
if e.ID == anchor {
idx := i
resp.TargetIndex = &idx
break
}
}
}
writeJSON(w, http.StatusOK, resp)
return
}
// Old contract: ASC (oldest → newest).
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
entries[i], entries[j] = entries[j], entries[i]
}
// Old client does `data: timeline = []` which defaults undefined, not
// null — render an empty issue as "[]" not "null".
entries := h.mergeTimeline(r, comments, activities, true)
if entries == nil {
entries = []TimelineEntry{}
}
writeJSON(w, http.StatusOK, entries)
}
// listTimelineLatest fetches the latest page (no cursor). <limit> is the
// COMMENT page size (#1857); activity rows ride along at the same per-call
// SQL cap but do not consume the page budget — has_more_before is gated on
// comments alone, so a chatty agent's status flips can't push real comments
// off-page.
func (h *Handler) listTimelineLatest(w http.ResponseWriter, r *http.Request, issue db.Issue, limit int) {
ctx := r.Context()
// Over-fetch comments by one so commentOverflow can distinguish "exactly
// <limit> comments exist" (no Show older needed) from ">limit comments
// exist" (Show older required).
rawComments, err := h.Queries.ListCommentsLatest(ctx, db.ListCommentsLatestParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID, Limit: int32(limit + 1),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
comments, hasMoreComments := commentOverflow(rawComments, limit)
activities, err := h.Queries.ListActivitiesLatest(ctx, db.ListActivitiesLatestParams{
IssueID: issue.ID, Limit: int32(limit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
entries := h.mergeTimelineDesc(r, comments, activities)
resp := TimelineResponse{Entries: entries}
resp.HasMoreBefore = hasMoreComments
// Per-pool boundaries. For latest mode there is no input cursor; if a
// pool returned no rows it carries from the other pool so the encoded
// payload stays self-contained. Future calls won't fetch new rows for
// the empty pool anyway (latest with 0 of one type means the issue has
// none), so the carry value is purely cosmetic.
cOldest, cNewest := commentBoundsDesc(comments, cursorPos{})
aOldest, aNewest := activityBoundsDesc(activities, cursorPos{})
if len(comments) == 0 {
cOldest, cNewest = aOldest, aNewest
}
if len(activities) == 0 {
aOldest, aNewest = cOldest, cNewest
}
if resp.HasMoreBefore && len(entries) > 0 {
c := encodeTimelineCursor(cOldest, aOldest)
resp.NextCursor = &c
}
if len(entries) > 0 {
c := encodeTimelineCursor(cNewest, aNewest)
resp.PrevCursor = &c
}
// has_more_after is always false on the latest page by definition.
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) listTimelineBefore(w http.ResponseWriter, r *http.Request, issue db.Issue, cursor string, limit int) {
ctx := r.Context()
inComment, inActivity, err := decodeTimelineCursor(cursor)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return
}
rawComments, err := h.Queries.ListCommentsBefore(ctx, db.ListCommentsBeforeParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
Column3: inComment.T, Column4: inComment.ID, Limit: int32(limit + 1),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
comments, hasMoreComments := commentOverflow(rawComments, limit)
activities, err := h.Queries.ListActivitiesBefore(ctx, db.ListActivitiesBeforeParams{
IssueID: issue.ID, Column2: inActivity.T, Column3: inActivity.ID, Limit: int32(limit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
entries := h.mergeTimelineDesc(r, comments, activities)
resp := TimelineResponse{
Entries: entries,
HasMoreAfter: true, // we're paging older from a known position, so newer exists
}
resp.HasMoreBefore = hasMoreComments
// Per-pool boundaries. Empty pool carries forward from the input cursor
// so subsequent older pages keep advancing past previously-paginated rows
// in that pool.
cOldest, cNewest := commentBoundsDesc(comments, inComment)
aOldest, aNewest := activityBoundsDesc(activities, inActivity)
if resp.HasMoreBefore && len(entries) > 0 {
c := encodeTimelineCursor(cOldest, aOldest)
resp.NextCursor = &c
}
if len(entries) > 0 {
c := encodeTimelineCursor(cNewest, aNewest)
resp.PrevCursor = &c
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) listTimelineAfter(w http.ResponseWriter, r *http.Request, issue db.Issue, cursor string, limit int) {
ctx := r.Context()
inComment, inActivity, err := decodeTimelineCursor(cursor)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return
}
rawComments, err := h.Queries.ListCommentsAfter(ctx, db.ListCommentsAfterParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
Column3: inComment.T, Column4: inComment.ID, Limit: int32(limit + 1),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
// ASC fetch returns oldest-first; trimming to the first <limit> keeps
// the rows closest to the cursor and drops the (limit+1)th newest as the
// overflow probe.
comments, hasMoreComments := commentOverflow(rawComments, limit)
activities, err := h.Queries.ListActivitiesAfter(ctx, db.ListActivitiesAfterParams{
IssueID: issue.ID, Column2: inActivity.T, Column3: inActivity.ID, Limit: int32(limit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
// Both queries returned ASC (older→newer); reverse to DESC for the
// response. No outer truncation: each pool is already capped by the SQL
// LIMIT, and dropping rows here would re-introduce the comments-pushed-
// off-page bug (#1857).
entries := h.mergeTimelineAscThenReverse(r, comments, activities)
resp := TimelineResponse{Entries: entries, HasMoreBefore: true}
resp.HasMoreAfter = hasMoreComments
cOldest, cNewest := commentBoundsAsc(comments, inComment)
aOldest, aNewest := activityBoundsAsc(activities, inActivity)
if resp.HasMoreAfter && len(entries) > 0 {
c := encodeTimelineCursor(cNewest, aNewest)
resp.PrevCursor = &c
}
if len(entries) > 0 {
c := encodeTimelineCursor(cOldest, aOldest)
resp.NextCursor = &c
}
writeJSON(w, http.StatusOK, resp)
}
// listTimelineAround anchors a window of size <limit> on a target entry,
// returning roughly half before and half after plus the target itself.
// This is the Inbox-jump / deep-link path: the target entry can be deep in
// the timeline, but the response is bounded so the browser never freezes.
func (h *Handler) listTimelineAround(w http.ResponseWriter, r *http.Request, issue db.Issue, targetID string, limit int) {
ctx := r.Context()
target, err := parseUUIDStrict(targetID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid around id")
return
}
// Resolve the target's (created_at, id). It can be either a comment or
// an activity; we don't ask the client to disambiguate.
var anchorTime pgtype.Timestamptz
var anchorID pgtype.UUID
if c, cErr := h.Queries.GetCommentInWorkspace(ctx, db.GetCommentInWorkspaceParams{
ID: target, WorkspaceID: issue.WorkspaceID,
}); cErr == nil && c.IssueID == issue.ID {
anchorTime, anchorID = c.CreatedAt, c.ID
} else if a, aErr := h.Queries.GetActivity(ctx, target); aErr == nil &&
a.IssueID == issue.ID && a.WorkspaceID == issue.WorkspaceID {
anchorTime, anchorID = a.CreatedAt, a.ID
} else {
// Neither comment nor activity matched (or wrong workspace/issue).
// Don't leak existence — return 404 like other resource lookups.
if cErr != nil && !errors.Is(cErr, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "failed to resolve target")
return
}
writeError(w, http.StatusNotFound, "timeline entry not found")
return
}
half := limit / 2
if half < 1 {
half = 1
}
beforeLimit := half
afterLimit := limit - half - 1 // -1 for the anchor itself
if afterLimit < 0 {
afterLimit = 0
}
// Older half: keyset Before (anchor exclusive). Over-fetch comments by
// one to detect overflow exactly.
rawOlderComments, err := h.Queries.ListCommentsBefore(ctx, db.ListCommentsBeforeParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
Column3: anchorTime, Column4: anchorID, Limit: int32(beforeLimit + 1),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
olderComments, hasMoreOlderComments := commentOverflow(rawOlderComments, beforeLimit)
olderActivities, err := h.Queries.ListActivitiesBefore(ctx, db.ListActivitiesBeforeParams{
IssueID: issue.ID, Column2: anchorTime, Column3: anchorID, Limit: int32(beforeLimit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
olderEntries := h.mergeTimelineDesc(r, olderComments, olderActivities)
// Newer half: keyset After (anchor exclusive).
rawNewerComments, err := h.Queries.ListCommentsAfter(ctx, db.ListCommentsAfterParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
Column3: anchorTime, Column4: anchorID, Limit: int32(afterLimit + 1),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
newerComments, hasMoreNewerComments := commentOverflow(rawNewerComments, afterLimit)
newerActivities, err := h.Queries.ListActivitiesAfter(ctx, db.ListActivitiesAfterParams{
IssueID: issue.ID, Column2: anchorTime, Column3: anchorID, Limit: int32(afterLimit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
newerEntries := h.mergeTimelineAscThenReverse(r, newerComments, newerActivities)
// Build the anchor entry inline using the existing single-entry path.
anchorEntry, ok := h.fetchSingleEntry(r, issue, target)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to fetch anchor")
return
}
// Final stitch: newer (DESC) + anchor + older (DESC).
entries := make([]TimelineEntry, 0, len(newerEntries)+1+len(olderEntries))
entries = append(entries, newerEntries...)
entries = append(entries, anchorEntry)
entries = append(entries, olderEntries...)
targetIdx := len(newerEntries)
resp := TimelineResponse{
Entries: entries,
HasMoreBefore: hasMoreOlderComments,
HasMoreAfter: hasMoreNewerComments,
TargetIndex: &targetIdx,
}
// Per-pool boundaries on each half. Empty pools fall back to the anchor
// position, which is exclusive on both sides — so a follow-up Before /
// After call against the anchor returns no duplicates.
anchor := cursorPos{T: anchorTime, ID: anchorID}
olderCommentOldest, _ := commentBoundsDesc(olderComments, anchor)
olderActivityOldest, _ := activityBoundsDesc(olderActivities, anchor)
_, newerCommentNewest := commentBoundsAsc(newerComments, anchor)
_, newerActivityNewest := activityBoundsAsc(newerActivities, anchor)
if resp.HasMoreBefore {
c := encodeTimelineCursor(olderCommentOldest, olderActivityOldest)
resp.NextCursor = &c
}
if resp.HasMoreAfter {
c := encodeTimelineCursor(newerCommentNewest, newerActivityNewest)
resp.PrevCursor = &c
}
writeJSON(w, http.StatusOK, resp)
}
// fetchSingleEntry materializes a single TimelineEntry (comment or activity)
// for the around-mode anchor. Reactions/attachments come from the same batch
// helpers so the rendering is identical to the merge path.
func (h *Handler) fetchSingleEntry(r *http.Request, issue db.Issue, id pgtype.UUID) (TimelineEntry, bool) {
ctx := r.Context()
if c, err := h.Queries.GetCommentInWorkspace(ctx, db.GetCommentInWorkspaceParams{
ID: id, WorkspaceID: issue.WorkspaceID,
}); err == nil && c.IssueID == issue.ID {
return h.commentsToEntries(r, []db.Comment{c})[0], true
}
if a, err := h.Queries.GetActivity(ctx, id); err == nil &&
a.IssueID == issue.ID && a.WorkspaceID == issue.WorkspaceID {
return activityToEntry(a), true
}
return TimelineEntry{}, false
}
// commentOverflow trims an over-fetched comment slice to <limit> and reports
// whether the SQL returned more rows than the visible budget. Callers
// over-fetch by one (limit+1) so the boolean is exact even when the issue has
// EXACTLY <limit> comments — the prior `len >= limit` check returned true in
// that case and rendered a "Show older" affordance that revealed nothing.
//
// Activity rows do not gate pagination (#1857): a dense activity stream from
// agent runs / status flips would otherwise trigger "show older" on issues
// with only a handful of real comments. Activities therefore stay capped at
// <limit> with no overflow probe.
func commentOverflow(rows []db.Comment, limit int) ([]db.Comment, bool) {
if limit <= 0 {
return rows, false
}
if len(rows) > limit {
return rows[:limit], true
}
return rows, false
}
// mergeTimelineDesc returns comments + activities merged DESC by
// (created_at, id). No truncation: both pools are individually capped at the
// SQL layer, and dropping rows here would re-introduce the bug where dense
// activity pushed real comments off-page (#1857). Callers that need an outer
// safety cap (legacy compat path) apply it themselves.
func (h *Handler) mergeTimelineDesc(r *http.Request, comments []db.Comment, activities []db.ActivityLog) []TimelineEntry {
// mergeTimeline merges comments and activities and returns them sorted by
// (created_at, id). When ascending=true, oldest first (the new flat-array
// contract); otherwise newest first (the wrapped legacy contract).
func (h *Handler) mergeTimeline(r *http.Request, comments []db.Comment, activities []db.ActivityLog, ascending bool) []TimelineEntry {
out := make([]TimelineEntry, 0, len(comments)+len(activities))
out = append(out, h.commentsToEntries(r, comments)...)
for _, a := range activities {
@@ -602,36 +141,19 @@ func (h *Handler) mergeTimelineDesc(r *http.Request, comments []db.Comment, acti
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt != out[j].CreatedAt {
if ascending {
return out[i].CreatedAt < out[j].CreatedAt
}
return out[i].CreatedAt > out[j].CreatedAt
}
if ascending {
return out[i].ID < out[j].ID
}
return out[i].ID > out[j].ID
})
return out
}
// mergeTimelineAscThenReverse takes comments + activities sorted ASC by
// (created_at, id) — the natural shape of an "after" keyset query — and
// returns them DESC for response consistency. No truncation, same reason as
// mergeTimelineDesc.
func (h *Handler) mergeTimelineAscThenReverse(r *http.Request, comments []db.Comment, activities []db.ActivityLog) []TimelineEntry {
out := make([]TimelineEntry, 0, len(comments)+len(activities))
out = append(out, h.commentsToEntries(r, comments)...)
for _, a := range activities {
out = append(out, activityToEntry(a))
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt != out[j].CreatedAt {
return out[i].CreatedAt < out[j].CreatedAt
}
return out[i].ID < out[j].ID
})
// Reverse to DESC.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// commentsToEntries fetches reactions + attachments for the given comments in
// one batch each and returns enriched TimelineEntry slices preserving order.
func (h *Handler) commentsToEntries(r *http.Request, comments []db.Comment) []TimelineEntry {

View File

@@ -6,31 +6,24 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// fetchTimeline issues a GET /timeline request with the given query string and
// returns the decoded TimelineResponse + HTTP status.
func fetchTimeline(t *testing.T, issueID, query string) (TimelineResponse, int) {
// fetchTimeline issues a GET /timeline request and returns the decoded entries
// + HTTP status. The endpoint returns a flat array of TimelineEntry sorted by
// (created_at, id) ascending (oldest first); see ListTimeline / #1929.
func fetchTimeline(t *testing.T, issueID string) ([]TimelineEntry, int) {
t.Helper()
url := "/api/issues/" + issueID + "/timeline"
if query != "" {
url += "?" + query
}
w := httptest.NewRecorder()
req := newRequest("GET", url, nil)
req := newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListTimeline(w, req)
var resp TimelineResponse
var entries []TimelineEntry
if w.Code == http.StatusOK {
json.NewDecoder(w.Body).Decode(&resp)
json.NewDecoder(w.Body).Decode(&entries)
}
return resp, w.Code
return entries, w.Code
}
// createIssueForTimeline returns a freshly-created issue id and registers a
@@ -58,9 +51,8 @@ func createIssueForTimeline(t *testing.T, title string) string {
}
// seedTimelineEntries inserts <commentN> comments + <activityN> activities for
// the given issue with descending timestamps (oldest first → newest last) so
// callers can reason about ordering. Returns the inserted comment + activity
// IDs in the order they were inserted (chronologically ascending).
// the given issue with ascending timestamps. Returns the inserted ids in the
// order they were inserted (chronologically ascending).
func seedTimelineEntries(t *testing.T, issueID string, commentN, activityN int) (commentIDs, activityIDs []string) {
t.Helper()
ctx := context.Background()
@@ -93,474 +85,137 @@ func seedTimelineEntries(t *testing.T, issueID string, commentN, activityN int)
return
}
func TestListTimeline_DefaultLatestPage(t *testing.T) {
issueID := createIssueForTimeline(t, "Latest page test")
// 80 comments triggers the comment overflow signal; activities are
// excluded so the per-page count is unambiguous (#1857: activities don't
// gate has_more_before).
seedTimelineEntries(t, issueID, 80, 0)
func TestListTimeline_ReturnsAllEntriesAscending(t *testing.T) {
issueID := createIssueForTimeline(t, "All entries test")
commentIDs, _ := seedTimelineEntries(t, issueID, 5, 0)
// Empty query string is now reserved for the legacy compat path; new
// client always sends ?limit=... so emulate that here.
resp, code := fetchTimeline(t, issueID, "limit=30")
if code != http.StatusOK {
t.Fatalf("expected 200, got %d", code)
entries, status := fetchTimeline(t, issueID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
if len(resp.Entries) != 30 {
t.Fatalf("expected 30 entries on default page, got %d", len(resp.Entries))
// Handler tests don't register the activity listener (that lives in
// cmd/server), so issue creation does not seed an auto-activity here.
// We assert directly on the seeded comments.
commentEntries := []TimelineEntry{}
for _, e := range entries {
if e.Type == "comment" {
commentEntries = append(commentEntries, e)
}
}
if !resp.HasMoreBefore {
t.Fatalf("expected has_more_before=true with 80 comments")
if got, want := len(commentEntries), len(commentIDs); got != want {
t.Fatalf("comment count = %d, want %d", got, want)
}
if resp.HasMoreAfter {
t.Fatalf("latest page must report has_more_after=false")
}
if resp.NextCursor == nil {
t.Fatalf("expected next_cursor on full page")
}
// DESC order: first entry's timestamp must be >= last entry's.
if resp.Entries[0].CreatedAt < resp.Entries[len(resp.Entries)-1].CreatedAt {
t.Fatalf("expected DESC order, first=%s last=%s",
resp.Entries[0].CreatedAt, resp.Entries[len(resp.Entries)-1].CreatedAt)
for i, e := range commentEntries {
if e.ID != commentIDs[i] {
t.Errorf("entry %d: id = %s, want %s", i, e.ID, commentIDs[i])
}
}
}
func TestListTimeline_BeforeCursorWalksOlder(t *testing.T) {
issueID := createIssueForTimeline(t, "Before cursor test")
// Comments-only so the page-count assertions are stable. limit=20 →
// 20 comments per page, no activities to inflate the totals.
seedTimelineEntries(t, issueID, 60, 0)
func TestListTimeline_MergesCommentsAndActivities(t *testing.T) {
issueID := createIssueForTimeline(t, "Merged entries test")
seedTimelineEntries(t, issueID, 3, 2)
first, _ := fetchTimeline(t, issueID, "limit=20")
if len(first.Entries) != 20 {
t.Fatalf("first page: expected 20, got %d", len(first.Entries))
}
if first.NextCursor == nil {
t.Fatalf("first page should have next_cursor")
}
second, code := fetchTimeline(t, issueID, "limit=20&before="+*first.NextCursor)
if code != http.StatusOK {
t.Fatalf("second page: expected 200, got %d", code)
}
if len(second.Entries) != 20 {
t.Fatalf("second page: expected 20, got %d", len(second.Entries))
}
if !second.HasMoreAfter {
t.Fatalf("second page must report has_more_after=true (we paged backward)")
}
// No overlap: oldest of first page must be strictly newer than newest of second.
firstTail := first.Entries[len(first.Entries)-1]
secondHead := second.Entries[0]
if firstTail.CreatedAt < secondHead.CreatedAt {
t.Fatalf("pages overlap: firstTail=%s secondHead=%s",
firstTail.CreatedAt, secondHead.CreatedAt)
}
}
func TestListTimeline_AfterCursorWalksNewer(t *testing.T) {
issueID := createIssueForTimeline(t, "After cursor test")
// Comments-only seed so cursor walking depends on comment overflow alone
// (#1857: activities don't gate has_more_after either).
seedTimelineEntries(t, issueID, 60, 0)
first, _ := fetchTimeline(t, issueID, "limit=20")
if first.NextCursor == nil {
t.Fatalf("first page should have next_cursor")
}
older, _ := fetchTimeline(t, issueID, "limit=20&before="+*first.NextCursor)
if older.PrevCursor == nil {
t.Fatalf("older page should have prev_cursor")
}
// Walk back forward: ?after=older.prev_cursor should land on entries
// newer than the older page's newest, i.e. overlap with first page.
newer, code := fetchTimeline(t, issueID, "limit=20&after="+*older.PrevCursor)
if code != http.StatusOK {
t.Fatalf("after page: expected 200, got %d", code)
}
if len(newer.Entries) == 0 {
t.Fatalf("after page should not be empty")
}
if !newer.HasMoreBefore {
t.Fatalf("after page must report has_more_before=true")
}
}
func TestListTimeline_AroundAnchorsOnTarget(t *testing.T) {
issueID := createIssueForTimeline(t, "Around test")
commentIDs, _ := seedTimelineEntries(t, issueID, 50, 0)
// commentIDs[0] is the OLDEST. Pick the 2nd-oldest as the anchor — far
// from the latest page so we can verify around mode actually works.
target := commentIDs[1]
resp, code := fetchTimeline(t, issueID, "around="+target+"&limit=20")
if code != http.StatusOK {
t.Fatalf("expected 200, got %d", code)
}
if resp.TargetIndex == nil {
t.Fatalf("expected target_index in around mode")
}
if len(resp.Entries) == 0 || resp.Entries[*resp.TargetIndex].ID != target {
t.Fatalf("target_index does not point at target id; got %s",
resp.Entries[*resp.TargetIndex].ID)
}
// Should have entries on both sides of the anchor (the 2nd-oldest has
// 1 older + many newer).
if !resp.HasMoreAfter {
t.Fatalf("around 2nd-oldest should report has_more_after=true")
}
}
func TestListTimeline_AroundUnknownTarget(t *testing.T) {
issueID := createIssueForTimeline(t, "Around 404 test")
seedTimelineEntries(t, issueID, 5, 0)
bogus := "00000000-0000-0000-0000-000000000001"
_, code := fetchTimeline(t, issueID, "around="+bogus)
if code != http.StatusNotFound {
t.Fatalf("expected 404 for unknown anchor, got %d", code)
}
}
func TestListTimeline_LimitOverMaxRejected(t *testing.T) {
issueID := createIssueForTimeline(t, "Limit cap test")
seedTimelineEntries(t, issueID, 1, 0)
_, code := fetchTimeline(t, issueID, "limit=500")
if code != http.StatusBadRequest {
t.Fatalf("expected 400 for limit=500, got %d", code)
}
}
func TestListTimeline_MutuallyExclusiveCursorParams(t *testing.T) {
issueID := createIssueForTimeline(t, "Mutex test")
seedTimelineEntries(t, issueID, 1, 0)
_, code := fetchTimeline(t, issueID, "before=abc&after=def")
if code != http.StatusBadRequest {
t.Fatalf("before+after should 400, got %d", code)
}
}
func TestListTimeline_InvalidCursorRejected(t *testing.T) {
issueID := createIssueForTimeline(t, "Bad cursor test")
seedTimelineEntries(t, issueID, 1, 0)
_, code := fetchTimeline(t, issueID, "before=not-base64-json")
if code != http.StatusBadRequest {
t.Fatalf("invalid cursor should 400, got %d", code)
}
}
func TestListTimeline_MergedCommentAndActivity(t *testing.T) {
issueID := createIssueForTimeline(t, "Merge test")
ctx := context.Background()
// Use explicit, well-separated timestamps so the DESC ordering assertion
// is deterministic regardless of clock granularity.
older := time.Now().UTC().Add(-2 * time.Hour)
newer := older.Add(1 * time.Hour)
// Older row: activity.
if _, err := testPool.Exec(ctx, `
INSERT INTO activity_log (workspace_id, issue_id, actor_type, actor_id, action, details, created_at)
VALUES ($1, $2, 'member', $3, 'created', '{}'::jsonb, $4)
`, testWorkspaceID, issueID, testUserID, older); err != nil {
t.Fatalf("seed activity: %v", err)
}
// Newer row: comment.
if _, err := testPool.Exec(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at, updated_at)
VALUES ($1, $2, 'member', $3, 'merge test comment', 'comment', $4, $4)
`, issueID, testWorkspaceID, testUserID, newer); err != nil {
t.Fatalf("seed comment: %v", err)
}
resp, code := fetchTimeline(t, issueID, "limit=50")
if code != http.StatusOK {
t.Fatalf("expected 200, got %d", code)
}
if len(resp.Entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(resp.Entries))
}
// DESC: comment (newer) at index 0, activity (older) at index 1.
if resp.Entries[0].Type != "comment" || resp.Entries[1].Type != "activity" {
t.Fatalf("merge order wrong: got %s/%s, want comment/activity",
resp.Entries[0].Type, resp.Entries[1].Type)
}
if !strings.Contains(*resp.Entries[0].Content, "merge test") {
t.Fatalf("comment content lost in merge: %v", resp.Entries[0].Content)
}
}
// TestListTimeline_LegacyShapeForPreCursorClients pins the backwards-compat
// contract for clients that predate cursor pagination (#2128). They call
// /timeline with no query string and read the body as TimelineEntry[]
// directly — returning the new wrapped shape there is what caused #2143 /
// #2147. Asserts: array shape, ASC order, "[]" (not "null") on empty issue.
func TestListTimeline_LegacyShapeForPreCursorClients(t *testing.T) {
issueID := createIssueForTimeline(t, "Legacy compat test")
seedTimelineEntries(t, issueID, 3, 2) // 5 total
w := httptest.NewRecorder()
req := newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListTimeline(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Must decode as a bare array, not the wrapped TimelineResponse.
var entries []TimelineEntry
if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
t.Fatalf("legacy response must be a JSON array: %v", err)
}
if len(entries) != 5 {
t.Fatalf("expected 5 entries (3 comments + 2 activities), got %d", len(entries))
entries, status := fetchTimeline(t, issueID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
// Verify chronological non-decreasing order across types.
for i := 1; i < len(entries); i++ {
if entries[i-1].CreatedAt > entries[i].CreatedAt {
t.Fatalf("legacy contract requires ASC order, got %s before %s",
entries[i-1].CreatedAt, entries[i].CreatedAt)
t.Errorf("not chronological at %d: %q then %q",
i, entries[i-1].CreatedAt, entries[i].CreatedAt)
}
}
// Empty issue must render as "[]" (not "null") — old client does
// `data: timeline = []` which defaults undefined but not null.
emptyID := createIssueForTimeline(t, "Empty legacy test")
w2 := httptest.NewRecorder()
req2 := newRequest("GET", "/api/issues/"+emptyID+"/timeline", nil)
req2 = withURLParam(req2, "id", emptyID)
testHandler.ListTimeline(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("empty issue: expected 200, got %d", w2.Code)
}
if got := strings.TrimSpace(w2.Body.String()); got != "[]" {
t.Fatalf("empty issue must render as [], got %q", got)
// 3 seeded comments + 2 seeded activities = 5. Handler tests don't
// register the activity listener, so there is no auto issue-created row.
if got, want := len(entries), 5; got != want {
t.Fatalf("entries = %d, want %d", got, want)
}
}
// TestTimelineCursor_RoundTrip pins the dual-pool cursor format. Cursors carry
// independent comment and activity positions (#1857 follow-up) so future
// pages walk each pool past its own boundary instead of skipping rows when
// one pool's oldest is older than the other's.
func TestTimelineCursor_RoundTrip(t *testing.T) {
cT := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
aT := time.Date(2026, 5, 1, 11, 0, 0, 0, time.UTC)
cID, _ := parseUUIDStrict("11111111-1111-1111-1111-111111111111")
aID, _ := parseUUIDStrict("22222222-2222-2222-2222-222222222222")
in := struct {
comment, activity cursorPos
}{
comment: cursorPos{T: pgtype.Timestamptz{Time: cT, Valid: true}, ID: cID},
activity: cursorPos{T: pgtype.Timestamptz{Time: aT, Valid: true}, ID: aID},
}
encoded := encodeTimelineCursor(in.comment, in.activity)
gotC, gotA, err := decodeTimelineCursor(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !gotC.T.Time.Equal(cT) || !gotA.T.Time.Equal(aT) {
t.Fatalf("timestamps did not round-trip: comment=%s activity=%s", gotC.T.Time, gotA.T.Time)
}
if uuidToString(gotC.ID) != "11111111-1111-1111-1111-111111111111" {
t.Fatalf("comment id did not round-trip: %s", uuidToString(gotC.ID))
}
if uuidToString(gotA.ID) != "22222222-2222-2222-2222-222222222222" {
t.Fatalf("activity id did not round-trip: %s", uuidToString(gotA.ID))
}
// Garbage cursor → error, never panics.
if _, _, err := decodeTimelineCursor("not-base64"); err == nil {
t.Fatalf("expected decode error for garbage input")
// fetchTimelineWrapped exercises the legacy wrapped response shape that
// stale Multica.app v0.2.26+ builds still expect — sending any of
// limit/before/after/around makes the server emit a TimelinePage-style
// object (entries DESC, null cursors, has_more_*=false) instead of the new
// flat array. Used to verify the boundary-compat path doesn't regress.
func fetchTimelineWrapped(t *testing.T, issueID, query string) (timelinePaginatedResponse, int) {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("GET", "/api/issues/"+issueID+"/timeline?"+query, nil)
req = withURLParam(req, "id", issueID)
testHandler.ListTimeline(w, req)
var resp timelinePaginatedResponse
if w.Code == http.StatusOK {
json.NewDecoder(w.Body).Decode(&resp)
}
return resp, w.Code
}
// TestCommentOverflow pins the over-fetch / trim contract that gates "Show
// older". Callers query the SQL with limit+1 and pass the raw rows in; the
// helper trims to <limit> and reports hasMore. The boundary the user flagged
// — exactly <limit> comments exist — must report hasMore=false so no
// affordance appears for content that doesn't exist.
func TestCommentOverflow(t *testing.T) {
mk := func(n int) []db.Comment {
out := make([]db.Comment, n)
return out
}
cases := []struct {
name string
fetched int // rows the SQL returned (caller asked for limit+1)
limit int
wantTrimmed int
wantMore bool
}{
{"empty page", 0, 30, 0, false},
{"partial page", 5, 30, 5, false},
// Issue has exactly limit comments — caller asked for limit+1 and got
// only limit back. No older content; "Show older" must NOT appear.
{"exactly limit comments", 30, 30, 30, false},
// Issue has more than limit — caller asked for limit+1 and got
// limit+1 back. Trim the probe row, set hasMore=true.
{"one over limit", 31, 30, 30, true},
{"well over limit", 100, 30, 30, true},
{"limit zero rejects", 100, 0, 100, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rows, more := commentOverflow(mk(tc.fetched), tc.limit)
if len(rows) != tc.wantTrimmed {
t.Fatalf("trimmed length: got %d, want %d", len(rows), tc.wantTrimmed)
}
if more != tc.wantMore {
t.Fatalf("hasMore: got %v, want %v", more, tc.wantMore)
}
})
}
}
// Boundary-compat: a stale client between #2128 and #1929 sends ?limit=50
// and parses the response with TimelinePageSchema. The handler must keep
// returning the wrapped object so that path doesn't fall back to an empty
// timeline.
func TestListTimeline_LegacyWrappedShape_OnPaginationParams(t *testing.T) {
issueID := createIssueForTimeline(t, "Legacy wrapped shape test")
commentIDs, _ := seedTimelineEntries(t, issueID, 3, 0)
// TestListTimeline_PerPoolCursorWalksAllComments reproduces the GPT-Boy
// blocker on PR #2253: when activities sit older than every fetched comment,
// a single shared cursor anchored on the merged-page boundary points at the
// oldest activity, and the next "show older" call's `ListCommentsBefore` hits
// activity time → skips every unreturned comment in between. The dual-pool
// cursor walks each pool independently so the full comment list stays
// reachable.
func TestListTimeline_PerPoolCursorWalksAllComments(t *testing.T) {
issueID := createIssueForTimeline(t, "GPT-Boy per-pool cursor regression")
ctx := context.Background()
// Seed 30 activities in the older block, then 80 comments strictly newer
// than every activity. seedTimelineEntries inserts comments first then
// activities, which is the wrong order for this scenario, so seed manually.
const activityN, commentN = 30, 80
base := time.Now().UTC().Add(-time.Duration(activityN+commentN) * time.Minute)
for i := 0; i < activityN; i++ {
ts := base.Add(time.Duration(i) * time.Minute)
if _, err := testPool.Exec(ctx, `
INSERT INTO activity_log (workspace_id, issue_id, actor_type, actor_id, action, details, created_at)
VALUES ($1, $2, 'member', $3, 'status_changed', '{"from":"todo","to":"in_progress"}'::jsonb, $4)
`, testWorkspaceID, issueID, testUserID, ts); err != nil {
t.Fatalf("seed activity %d: %v", i, err)
}
resp, status := fetchTimelineWrapped(t, issueID, "limit=50")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
commentIDs := make([]string, 0, commentN)
for i := 0; i < commentN; i++ {
var id string
ts := base.Add(time.Duration(activityN+i) * time.Minute)
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at, updated_at)
VALUES ($1, $2, 'member', $3, $4, 'comment', $5, $5)
RETURNING id
`, issueID, testWorkspaceID, testUserID, fmt.Sprintf("comment %d", i), ts).Scan(&id); err != nil {
t.Fatalf("seed comment %d: %v", i, err)
}
commentIDs = append(commentIDs, id)
if resp.HasMoreBefore || resp.HasMoreAfter {
t.Errorf("has_more_*: want false/false, got before=%v after=%v",
resp.HasMoreBefore, resp.HasMoreAfter)
}
// Walk older pages until exhausted, collecting every comment id seen.
seen := map[string]bool{}
cursor := ""
for page := 0; page < 10; page++ { // safety bound — true exit is has_more_before=false
query := "limit=30"
if cursor != "" {
query += "&before=" + cursor
}
resp, code := fetchTimeline(t, issueID, query)
if code != http.StatusOK {
t.Fatalf("page %d: expected 200, got %d", page, code)
}
for _, e := range resp.Entries {
if e.Type == "comment" {
seen[e.ID] = true
}
}
if !resp.HasMoreBefore {
break
}
if resp.NextCursor == nil {
t.Fatalf("page %d: has_more_before=true but next_cursor missing", page)
}
cursor = *resp.NextCursor
if resp.NextCursor != nil || resp.PrevCursor != nil {
t.Errorf("cursors: want nil/nil, got next=%v prev=%v", resp.NextCursor, resp.PrevCursor)
}
// All 80 seeded comments must be reachable through the cursor walk —
// pre-fix, the 50 unreturned comments after page 1 stayed hidden because
// the shared cursor skipped past them via the activity timestamp.
if len(seen) != commentN {
missing := []string{}
for _, id := range commentIDs {
if !seen[id] {
missing = append(missing, id)
}
}
t.Fatalf("expected to see all %d comments via cursor walk, saw %d. Missing: %v",
commentN, len(seen), missing)
}
}
// TestListTimeline_ExactlyLimitCommentsHidesShowOlder pins the boundary the
// user flagged: an issue with exactly <limit> comments must NOT report
// has_more_before. Pre-fix the gate was `len(comments) >= limit`, which
// returned true and rendered a "Show older" button that revealed nothing —
// older clicks fetched zero rows. The over-fetch + trim probe makes the
// boundary exact.
func TestListTimeline_ExactlyLimitCommentsHidesShowOlder(t *testing.T) {
issueID := createIssueForTimeline(t, "exactly limit comments boundary")
seedTimelineEntries(t, issueID, 30, 0)
resp, code := fetchTimeline(t, issueID, "limit=30")
if code != http.StatusOK {
t.Fatalf("expected 200, got %d", code)
}
if len(resp.Entries) != 30 {
t.Fatalf("expected 30 entries on first page, got %d", len(resp.Entries))
}
if resp.HasMoreBefore {
t.Fatalf("has_more_before must be false when comments == limit (issue has nothing older)")
}
if resp.NextCursor != nil {
t.Fatalf("next_cursor must be nil when has_more_before is false, got %q", *resp.NextCursor)
}
}
// TestListTimeline_DenseActivityDoesNotHideComments reproduces #1857: an issue
// with sparse comments but dense activity (status flips, agent runs) used to
// trigger has_more_before because activities consumed the same page budget.
// Real comments would get pushed off the visible page and users would think
// the discussion had vanished. Post-fix, has_more_before is gated on comments
// alone, so the entire conversation stays visible without "show older".
func TestListTimeline_DenseActivityDoesNotHideComments(t *testing.T) {
issueID := createIssueForTimeline(t, "1857 sparse comments dense activity")
// 10 comments — well under the 30-comment page budget — paired with 60
// activities (an agent that flipped status / completed runs many times).
// seedTimelineEntries inserts comments first (older block), then activities
// (newer block), matching the typical "issue created → discussion → many
// agent runs" timeline shape.
commentIDs, _ := seedTimelineEntries(t, issueID, 10, 60)
resp, code := fetchTimeline(t, issueID, "limit=30")
if code != http.StatusOK {
t.Fatalf("expected 200, got %d", code)
}
if resp.HasMoreBefore {
t.Fatalf("has_more_before must be false when comments < limit, even if activities are dense (#1857)")
}
// Every seeded comment must be on the first page — none should be hidden
// behind a "show older" gate on an issue with so few comments.
commentSeen := map[string]bool{}
// DESC order: most recent comment first; activity from issue-creation
// sits at the bottom.
commentEntries := []TimelineEntry{}
for _, e := range resp.Entries {
if e.Type == "comment" {
commentSeen[e.ID] = true
commentEntries = append(commentEntries, e)
}
}
for _, id := range commentIDs {
if !commentSeen[id] {
t.Fatalf("comment %s missing from latest page — #1857 regressed", id)
if got, want := len(commentEntries), len(commentIDs); got != want {
t.Fatalf("comment count = %d, want %d", got, want)
}
for i, e := range commentEntries {
want := commentIDs[len(commentIDs)-1-i]
if e.ID != want {
t.Errorf("DESC entry %d: id = %s, want %s", i, e.ID, want)
}
}
}
func TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex(t *testing.T) {
issueID := createIssueForTimeline(t, "Around target index test")
commentIDs, _ := seedTimelineEntries(t, issueID, 5, 0)
anchor := commentIDs[2] // pick a middle comment
resp, status := fetchTimelineWrapped(t, issueID, "around="+anchor)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
if resp.TargetIndex == nil {
t.Fatalf("target_index: want non-nil for around mode")
}
if got := resp.Entries[*resp.TargetIndex].ID; got != anchor {
t.Errorf("target_index points at %s, want anchor %s", got, anchor)
}
}
func TestListTimeline_EmptyIssue(t *testing.T) {
issueID := createIssueForTimeline(t, "Empty timeline test")
entries, status := fetchTimeline(t, issueID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
// Handler tests don't wire the activity listener, so a freshly-created
// issue with no comments has an empty timeline.
if got := len(entries); got != 0 {
t.Fatalf("entries = %d, want 0", got)
}
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
@@ -59,6 +58,13 @@ func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments [
}
}
// commentHardCap bounds the comments returned per issue. Sized as a defensive
// safety net rather than a UX paging window: prod p99 is ~30 comments and
// the all-time max observed is ~1.1k, so 2000 leaves ~2x headroom while still
// preventing a runaway response if some user manages to accumulate a wild
// number of rows on a single issue.
const commentHardCap = 2000
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
@@ -66,31 +72,12 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
return
}
// Parse optional pagination query params.
q := r.URL.Query()
var limit, offset int32
var hasPagination bool
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "invalid limit parameter")
return
}
limit = int32(n)
hasPagination = true
}
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
writeError(w, http.StatusBadRequest, "invalid offset parameter")
return
}
offset = int32(n)
hasPagination = true
}
// Only `since` is honoured — used by the CLI's `--since` agent-polling
// flow to fetch incremental comments. The previous limit/offset cursor
// was ripped out (#1929): time-based pagination breaks reply threads,
// and at the actual data sizes there is no win from paging.
var sinceTime pgtype.Timestamptz
if v := q.Get("since"); v != "" {
if v := r.URL.Query().Get("since"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid since parameter; expected RFC3339 format")
@@ -101,39 +88,18 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
var comments []db.Comment
var err error
// When neither limit nor offset is specified, default to the most recent
// 50 comments. The previous behavior returned every comment unbounded,
// which let an agent or browser call accidentally pull thousands of rows
// (see issue #1968). Callers that want everything must opt in with a
// large explicit --limit; a 100-cap on the server side stays in place via
// the timeline endpoint, but this endpoint is used by humans + the CLI
// where the cap is the documented 50 default.
if !hasPagination {
limit = 50
hasPagination = true
}
switch {
case sinceTime.Valid:
if limit == 0 {
limit = 50
}
comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{
if sinceTime.Valid {
comments, err = h.Queries.ListCommentsSinceForIssue(r.Context(), db.ListCommentsSinceForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: sinceTime,
Limit: limit,
Offset: offset,
Limit: commentHardCap,
})
default:
if limit == 0 {
limit = 50
}
comments, err = h.Queries.ListCommentsPaginated(r.Context(), db.ListCommentsPaginatedParams{
} else {
comments, err = h.Queries.ListCommentsForIssue(r.Context(), db.ListCommentsForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Limit: limit,
Offset: offset,
Limit: commentHardCap,
})
}
if err != nil {
@@ -154,17 +120,6 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
}
// Include total count in response header when paginating.
if hasPagination {
total, countErr := h.Queries.CountComments(r.Context(), db.CountCommentsParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
if countErr == nil {
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
}
}
writeJSON(w, http.StatusOK, resp)
}
@@ -743,4 +698,3 @@ func (h *Handler) UnresolveComment(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -102,8 +102,6 @@ SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, create
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
@@ -120,120 +118,22 @@ func (q *Queries) GetActivity(ctx context.Context, id pgtype.UUID) (ActivityLog,
return i, err
}
const listActivitiesAfter = `-- name: ListActivitiesAfter :many
const listActivitiesForIssue = `-- name: ListActivitiesForIssue :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 {
type ListActivitiesForIssueParams 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)
// All activities for an issue in chronological order, capped at $2 (DB safety
// net to bound the response).
func (q *Queries) ListActivitiesForIssue(ctx context.Context, arg ListActivitiesForIssueParams) ([]ActivityLog, error) {
rows, err := q.db.Query(ctx, listActivitiesForIssue, arg.IssueID, arg.Limit)
if err != nil {
return nil, err
}

View File

@@ -182,140 +182,24 @@ func (q *Queries) HasAgentRepliedInThread(ctx context.Context, arg HasAgentRepli
return has_replied, err
}
const listCommentsAfter = `-- name: ListCommentsAfter :many
const listCommentsForIssue = `-- name: ListCommentsForIssue :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2
AND (created_at, id) > ($3::timestamptz, $4::uuid)
ORDER BY created_at ASC, id ASC
LIMIT $5
`
type ListCommentsAfterParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Column3 pgtype.Timestamptz `json:"column_3"`
Column4 pgtype.UUID `json:"column_4"`
Limit int32 `json:"limit"`
}
// Keyset pagination: comments newer than ($3, $4) tuple. Returns ASC because
// "newer" pagination naturally walks forward in time; the merge layer
// normalizes to the response order.
func (q *Queries) ListCommentsAfter(ctx context.Context, arg ListCommentsAfterParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsAfter,
arg.IssueID,
arg.WorkspaceID,
arg.Column3,
arg.Column4,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsBefore = `-- name: ListCommentsBefore :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2
AND (created_at, id) < ($3::timestamptz, $4::uuid)
ORDER BY created_at DESC, id DESC
LIMIT $5
`
type ListCommentsBeforeParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Column3 pgtype.Timestamptz `json:"column_3"`
Column4 pgtype.UUID `json:"column_4"`
Limit int32 `json:"limit"`
}
// Keyset pagination: comments older than ($3, $4) tuple. Returns DESC so the
// caller can stitch pages without re-sorting.
func (q *Queries) ListCommentsBefore(ctx context.Context, arg ListCommentsBeforeParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsBefore,
arg.IssueID,
arg.WorkspaceID,
arg.Column3,
arg.Column4,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsLatest = `-- name: ListCommentsLatest :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at DESC, id DESC
LIMIT $3
`
type ListCommentsLatestParams struct {
type ListCommentsForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
}
// Top N comments for an issue, newest first. Backs the default cursor
// pagination entry point (no cursor → return the most recent page).
func (q *Queries) ListCommentsLatest(ctx context.Context, arg ListCommentsLatestParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsLatest, arg.IssueID, arg.WorkspaceID, arg.Limit)
// All comments for an issue in chronological order, capped at $3 (DB safety
// net). Issue p99 is ~30 comments, max ever observed in prod is ~1.1k, so
// the handler-side cap of 2000 is purely defensive.
func (q *Queries) ListCommentsForIssue(ctx context.Context, arg ListCommentsForIssueParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsForIssue, arg.IssueID, arg.WorkspaceID, arg.Limit)
if err != nil {
return nil, err
}
@@ -348,127 +232,28 @@ func (q *Queries) ListCommentsLatest(ctx context.Context, arg ListCommentsLatest
return items, nil
}
const listCommentsPaginated = `-- name: ListCommentsPaginated :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC
LIMIT $3 OFFSET $4
`
type ListCommentsPaginatedParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListCommentsPaginated(ctx context.Context, arg ListCommentsPaginatedParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsPaginated,
arg.IssueID,
arg.WorkspaceID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsSince = `-- name: ListCommentsSince :many
const listCommentsSinceForIssue = `-- name: ListCommentsSinceForIssue :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC
ORDER BY created_at ASC, id ASC
LIMIT $4
`
type ListCommentsSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) ListCommentsSince(ctx context.Context, arg ListCommentsSinceParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsSince, arg.IssueID, arg.WorkspaceID, arg.CreatedAt)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsSincePaginated = `-- name: ListCommentsSincePaginated :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC
LIMIT $4 OFFSET $5
`
type ListCommentsSincePaginatedParams struct {
type ListCommentsSinceForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListCommentsSincePaginated(ctx context.Context, arg ListCommentsSincePaginatedParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsSincePaginated,
// Comments created strictly after $3 in chronological order, capped at $4.
// Powers the CLI's `--since` agent-polling flow.
func (q *Queries) ListCommentsSinceForIssue(ctx context.Context, arg ListCommentsSinceForIssueParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsSinceForIssue,
arg.IssueID,
arg.WorkspaceID,
arg.CreatedAt,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err

View File

@@ -1,28 +1,12 @@
-- name: ListActivitiesLatest :many
-- Top N activities for an issue, newest first. Used by the cursor-paginated
-- timeline endpoint to assemble the latest page.
-- name: ListActivitiesForIssue :many
-- All activities for an issue in chronological order, capped at $2 (DB safety
-- net to bound the response).
SELECT * FROM activity_log
WHERE issue_id = $1
ORDER BY created_at DESC, id DESC
ORDER BY created_at ASC, id ASC
LIMIT $2;
-- name: ListActivitiesBefore :many
SELECT * FROM activity_log
WHERE issue_id = $1
AND (created_at, id) < ($2::timestamptz, $3::uuid)
ORDER BY created_at DESC, id DESC
LIMIT $4;
-- name: ListActivitiesAfter :many
SELECT * FROM activity_log
WHERE issue_id = $1
AND (created_at, id) > ($2::timestamptz, $3::uuid)
ORDER BY created_at ASC, id ASC
LIMIT $4;
-- name: GetActivity :one
-- Used by the around-id mode of ListTimeline to resolve an entry to its
-- (created_at, id) cursor when the entry is an activity.
SELECT * FROM activity_log
WHERE id = $1;

View File

@@ -1,46 +1,19 @@
-- name: ListCommentsPaginated :many
-- name: ListCommentsForIssue :many
-- All comments for an issue in chronological order, capped at $3 (DB safety
-- net). Issue p99 is ~30 comments, max ever observed in prod is ~1.1k, so
-- the handler-side cap of 2000 is purely defensive.
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC
LIMIT $3 OFFSET $4;
-- name: ListCommentsSince :many
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC;
-- name: ListCommentsSincePaginated :many
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC
LIMIT $4 OFFSET $5;
-- name: ListCommentsLatest :many
-- Top N comments for an issue, newest first. Backs the default cursor
-- pagination entry point (no cursor → return the most recent page).
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at DESC, id DESC
ORDER BY created_at ASC, id ASC
LIMIT $3;
-- name: ListCommentsBefore :many
-- Keyset pagination: comments older than ($3, $4) tuple. Returns DESC so the
-- caller can stitch pages without re-sorting.
-- name: ListCommentsSinceForIssue :many
-- Comments created strictly after $3 in chronological order, capped at $4.
-- Powers the CLI's `--since` agent-polling flow.
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
AND (created_at, id) < ($3::timestamptz, $4::uuid)
ORDER BY created_at DESC, id DESC
LIMIT $5;
-- name: ListCommentsAfter :many
-- Keyset pagination: comments newer than ($3, $4) tuple. Returns ASC because
-- "newer" pagination naturally walks forward in time; the merge layer
-- normalizes to the response order.
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
AND (created_at, id) > ($3::timestamptz, $4::uuid)
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC, id ASC
LIMIT $5;
LIMIT $4;
-- name: CountComments :one
SELECT count(*) FROM comment
@@ -105,4 +78,3 @@ UPDATE comment SET
updated_at = CASE WHEN resolved_at IS NOT NULL THEN now() ELSE updated_at END
WHERE id = $1
RETURNING *;