Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions apps/web/src/lib/agent-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { queryOptions } from "@tanstack/react-query";
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
import { apiClient } from "./api-client";
import type { SuccessEnvelope } from "./api-error";

Expand Down Expand Up @@ -476,15 +476,25 @@ export async function deleteEnvVar(

// ── Conversations ─────────────────────────────────────────────────────────────

export interface ConversationListResult {
items: AgentConversation[];
page_size: number;
next_cursor: string | null;
}

export const CONVERSATIONS_PAGE_SIZE = 20;

export async function listConversations(
projectId: string,
agentId?: string,
): Promise<AgentConversation[]> {
const params = agentId ? { agent_id: agentId } : undefined;
options?: { agentId?: string; cursor?: string; pageSize?: number },
): Promise<ConversationListResult> {
const { agentId, cursor, pageSize = CONVERSATIONS_PAGE_SIZE } = options ?? {};
const { data } = await apiClient.instance.get<
SuccessEnvelope<{ items: AgentConversation[] }>
>(`/projects/${projectId}/conversations`, { params });
return data.data.items;
SuccessEnvelope<ConversationListResult>
>(`/projects/${projectId}/conversations`, {
params: { agent_id: agentId, cursor, page_size: pageSize },
});
return data.data;
}

export async function getConversation(
Expand Down Expand Up @@ -641,14 +651,23 @@ export const acpBridgeStatusQueryOptions = (

// No refetchInterval: kept live via useProjectRealtime's socket-driven
// invalidation of the ["projects", projectId, "conversations"] prefix on
// every "agent.*" event instead of polling.
// every "agent.*" event instead of polling. Cursor-paginated — each page
// carries the opaque cursor to resume after its last item, so fetchNextPage
// just forwards the backend's next_cursor until it comes back null.
export const conversationsQueryOptions = (
projectId: string,
agentId?: string,
) =>
queryOptions({
infiniteQueryOptions({
queryKey: ["projects", projectId, "conversations", { agentId }],
queryFn: () => listConversations(projectId, agentId),
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
listConversations(projectId, {
agentId,
cursor: pageParam,
pageSize: CONVERSATIONS_PAGE_SIZE,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
});

export const conversationQueryOptions = (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import {
createFileRoute,
Link,
Outlet,
useParams,
} from "@tanstack/react-router";
import { Clock, MessageSquare, Zap } from "lucide-react";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
Expand All @@ -26,7 +27,7 @@ export const Route = createFileRoute(
)({
loader: async ({ context: { queryClient }, params: { projectId } }) => {
await Promise.all([
queryClient.ensureQueryData(conversationsQueryOptions(projectId)),
queryClient.ensureInfiniteQueryData(conversationsQueryOptions(projectId)),
queryClient.ensureQueryData(agentsQueryOptions(projectId)),
]);
},
Expand Down Expand Up @@ -116,16 +117,35 @@ function ConversationsLayout() {

useProjectRealtime(projectId);

const { data: conversations = [], isLoading } = useQuery(
conversationsQueryOptions(projectId),
);
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(conversationsQueryOptions(projectId));
const { data: agents = [] } = useQuery(agentsQueryOptions(projectId));
const agentsById = new Map(agents.map((a) => [a.id, a]));

const sortedConversations = [...conversations].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
// Pages are fetched newest-first (offset-paginated, ordered by created_at
// DESC on the backend), so concatenating them in fetch order already
// yields the correct display order — no client-side re-sort needed.
const conversations = data?.pages.flatMap((page) => page.items) ?? [];

const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
const container = scrollContainerRef.current;
const target = loadMoreRef.current;
if (!container || !target) return;

const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ root: container, rootMargin: "150px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);

return (
<div className="flex flex-1 min-h-0">
Expand All @@ -135,13 +155,16 @@ function ConversationsLayout() {
{t("conversationsPage.title")}
</h2>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto p-2 space-y-1.5"
>
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton
<Skeleton key={i} className="h-16 rounded-lg" />
))
) : sortedConversations.length === 0 ? (
) : conversations.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-14 px-3 text-center">
<MessageSquare className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
Expand All @@ -152,15 +175,24 @@ function ConversationsLayout() {
</p>
</div>
) : (
sortedConversations.map((conv) => (
<ConversationListItem
key={conv.id}
conv={conv}
agent={agentsById.get(conv.agent_id)}
projectId={projectId}
isActive={conv.id === activeConversationId}
/>
))
<>
{conversations.map((conv) => (
<ConversationListItem
key={conv.id}
conv={conv}
agent={agentsById.get(conv.agent_id)}
projectId={projectId}
isActive={conv.id === activeConversationId}
/>
))}
{hasNextPage && (
<div ref={loadMoreRef}>
{isFetchingNextPage && (
<Skeleton className="h-16 rounded-lg" />
)}
</div>
)}
</>
)}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { InfiniteData } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { MessageSquare } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
type AgentConversation,
type ConversationListResult,
conversationsQueryOptions,
} from "@/lib/agent-api";

Expand All @@ -12,7 +13,9 @@ export const Route = createFileRoute(
// The parent `conversations` layout route's loader already populated the
// conversations list in the query cache, so this can read it synchronously
// instead of refetching — landing directly on the most recently created
// conversation instead of showing a bare "pick one" screen.
// conversation instead of showing a bare "pick one" screen. The list's
// first page is ordered newest-first, so its first item is the latest
// conversation without needing to scan every loaded page.
//
// `preload` is true when this loader runs speculatively (e.g. the sidebar
// nav Link's hover-intent preload, since the router defaults to
Expand All @@ -22,15 +25,12 @@ export const Route = createFileRoute(
loader: ({ context: { queryClient }, params: { projectId }, preload }) => {
if (preload) return;

const conversations =
queryClient.getQueryData<AgentConversation[]>(
conversationsQueryOptions(projectId).queryKey,
) ?? [];
if (conversations.length === 0) return;

const latest = conversations.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b,
const data = queryClient.getQueryData<InfiniteData<ConversationListResult>>(
conversationsQueryOptions(projectId).queryKey,
);
const latest = data?.pages[0]?.items[0];
if (!latest) return;

throw redirect({
to: "/projects/$projectId/conversations/$conversationId",
params: { projectId, conversationId: latest.id },
Expand Down
2 changes: 2 additions & 0 deletions services/api/internal/apierr/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ const (
CodeAgentConversationAlreadyStopped Code = "AGENT_CONVERSATION_ALREADY_STOPPED"
// CodeAgentConversationBusy indicates a chat reply was sent while the agent is still responding to the previous one.
CodeAgentConversationBusy Code = "AGENT_CONVERSATION_BUSY"
// CodeAgentConversationInvalidCursor indicates a client-supplied pagination cursor failed to decode.
CodeAgentConversationInvalidCursor Code = "AGENT_CONVERSATION_INVALID_CURSOR"
// CodeAgentChatSessionNotFound indicates the requested chat session does not exist.
CodeAgentChatSessionNotFound Code = "AGENT_CHAT_SESSION_NOT_FOUND"
// CodeAgentEnvVarNotFound indicates the requested environment variable does not exist.
Expand Down
38 changes: 38 additions & 0 deletions services/api/internal/domain/agent/cursor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package agentdom

import (
"encoding/base64"
"encoding/json"
"fmt"
"time"
)

// ConversationCursor holds the stable ordering fields for keyset-based
// pagination over the conversations list, which is always ordered by
// created_at DESC, id DESC.
type ConversationCursor struct {
CreatedAt time.Time `json:"ca"`
ID string `json:"id"`
}

// EncodeConversationCursor builds an opaque base64 cursor from the last
// conversation on a page.
func EncodeConversationCursor(c *AgentConversation) string {
cur := ConversationCursor{CreatedAt: c.CreatedAt.UTC(), ID: c.ID.String()}
b, _ := json.Marshal(cur)
return base64.URLEncoding.EncodeToString(b)
}

// DecodeConversationCursor parses a cursor token produced by EncodeConversationCursor.
func DecodeConversationCursor(s string) (*ConversationCursor, error) {
b, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("decode cursor base64: %w", err)
}
var c ConversationCursor
if err := json.Unmarshal(b, &c); err != nil {
return nil, fmt.Errorf("decode cursor json: %w", err)
}
c.CreatedAt = c.CreatedAt.UTC()
return &c, nil
}
87 changes: 87 additions & 0 deletions services/api/internal/domain/agent/cursor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package agentdom

import (
"testing"
"time"

"github.com/google/uuid"
)

func TestConversationCursor_RoundTrip(t *testing.T) {
id := uuid.New()
createdAt := time.Date(2026, 3, 15, 10, 30, 0, 123456000, time.UTC)
conv := &AgentConversation{ID: id, CreatedAt: createdAt}

token := EncodeConversationCursor(conv)
if token == "" {
t.Fatal("expected non-empty cursor token")
}

cur, err := DecodeConversationCursor(token)
if err != nil {
t.Fatalf("decode: unexpected error: %v", err)
}
if cur.ID != id.String() {
t.Errorf("expected ID %q, got %q", id.String(), cur.ID)
}
if !cur.CreatedAt.Equal(createdAt) {
t.Errorf("expected CreatedAt %v, got %v", createdAt, cur.CreatedAt)
}
}

func TestEncodeConversationCursor_NormalizesToUTC(t *testing.T) {
loc := time.FixedZone("UTC+7", 7*60*60)
localTime := time.Date(2026, 3, 15, 17, 30, 0, 0, loc)
conv := &AgentConversation{ID: uuid.New(), CreatedAt: localTime}

token := EncodeConversationCursor(conv)
cur, err := DecodeConversationCursor(token)
if err != nil {
t.Fatalf("decode: unexpected error: %v", err)
}

if cur.CreatedAt.Location() != time.UTC {
t.Errorf("expected cursor CreatedAt in UTC, got location %v", cur.CreatedAt.Location())
}
if !cur.CreatedAt.Equal(localTime) {
t.Errorf("expected same instant as %v, got %v", localTime, cur.CreatedAt)
}
}

func TestDecodeConversationCursor_InvalidBase64(t *testing.T) {
_, err := DecodeConversationCursor("not-valid-base64!!!")
if err == nil {
t.Fatal("expected error for invalid base64 cursor, got nil")
}
}

func TestDecodeConversationCursor_InvalidJSON(t *testing.T) {
// Valid URL-safe base64, but the decoded bytes are not valid JSON.
_, err := DecodeConversationCursor("bm90LWpzb24tcGF5bG9hZA==")
if err == nil {
t.Fatal("expected error for cursor whose payload isn't valid JSON, got nil")
}
}

func TestDecodeConversationCursor_EmptyString(t *testing.T) {
// Empty input decodes to zero base64 bytes, which is not valid JSON — the
// handler only calls Decode when the cursor query param is non-empty, but
// the function itself must still fail closed rather than panic or return
// a misleadingly "valid" zero cursor.
_, err := DecodeConversationCursor("")
if err == nil {
t.Fatal("expected error for empty cursor string, got nil")
}
}

func TestDecodeConversationCursor_NormalizesResultToUTC(t *testing.T) {
// A cursor whose embedded timestamp carries a non-UTC offset should still
// come back normalized to UTC, regardless of how it was produced.
cur, err := DecodeConversationCursor("eyJjYSI6ICIyMDI2LTAzLTE1VDE3OjMwOjAwKzA3OjAwIiwgImlkIjogImFiYyJ9")
if err != nil {
t.Fatalf("decode: unexpected error: %v", err)
}
if cur.CreatedAt.Location() != time.UTC {
t.Errorf("expected UTC location, got %v", cur.CreatedAt.Location())
}
}
3 changes: 3 additions & 0 deletions services/api/internal/domain/agent/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ var (
// ErrConversationBusy is returned when a chat reply is sent while the
// session's current conversation is still mid-turn (status "running").
ErrConversationBusy = errors.New("agent is still responding to the previous message")
// ErrConversationInvalidCursor is returned when a client-supplied
// pagination cursor fails to decode.
ErrConversationInvalidCursor = errors.New("invalid pagination cursor")
)

// Chat session errors
Expand Down
15 changes: 8 additions & 7 deletions services/api/internal/domain/agent/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ type EnvVarRepository interface {

// ConversationRepository defines storage for agent conversations.
type ConversationRepository interface {
ListConversations(ctx context.Context, in ListConversationsFilter) ([]*AgentConversation, int64, error)
// ListConversations returns up to limit conversations matching the
// filter, ordered newest-first, plus whether more pages remain.
ListConversations(ctx context.Context, in ListConversationsFilter, limit int) (convs []*AgentConversation, hasMore bool, err error)
FindConversationByID(ctx context.Context, id uuid.UUID) (*AgentConversation, error)
// FindLatestConversationByChatSession returns the most recently created
// conversation for a chat session, or (nil, nil) if the session has none
Expand All @@ -94,10 +96,9 @@ type ChatSessionRepository interface {

// ListConversationsFilter carries optional filters for listing conversations.
type ListConversationsFilter struct {
AgentID *uuid.UUID
ProjectID *uuid.UUID
TaskID *uuid.UUID
Status *string
Limit int
Offset int
AgentID *uuid.UUID
ProjectID *uuid.UUID
TaskID *uuid.UUID
Status *string
CursorAfter *string // opaque base64 cursor; when set, resumes after this conversation
}
Loading
Loading