Compare commits

..

1 Commits

Author SHA1 Message Date
7dca2c3110 fix(api): normalize null lists to [] (backend serializes empty as null)
The Go backend serializes empty slices as JSON null in several places
(e.g. {count:0, topics:null} when no rows match). The SPA assumed
arrays and crashed on .length:

  TypeError: Cannot read properties of null (reading 'length')
    at Hh (TopicList.tsx:64:45)

Fix in api.ts so every list-returning helper normalizes null → [] once,
centrally. Three call sites covered:
  - listTopics       — topics field
  - listArguments    — arguments field
  - getAgentSummary  — recent_topics field

Components stay as-is; nullable handling moves out of UI code where it
belongs in the data layer. getTopic preserves camps:null vs camps:[]
distinction (null = not yet allocated, [] would be allocated-with-zero
which the allocator contract forbids — keep them meaningfully distinct).

No backend change. Tested on prod with empty topics — list page now
renders 'no topics match this filter' instead of crashing.
2026-05-24 01:18:26 +01:00

View File

@@ -64,7 +64,12 @@ async function request<T>(
// --------------------------------------------------------------------
// Topics
export function listTopics(filter: {
// Note on null vs []: the Go backend serializes empty slices as JSON
// null (`json:",omitempty"`-ish behavior in places), so all list-returning
// endpoints can return e.g. {count:0, topics:null}. Normalize to [] here
// so consumers can do .length / .map without per-call guards.
export async function listTopics(filter: {
status?: TopicStatus;
visibility?: Visibility;
limit?: number;
@@ -77,23 +82,35 @@ export function listTopics(filter: {
if (filter.limit) qs.set('limit', String(filter.limit));
if (filter.offset) qs.set('offset', String(filter.offset));
const suffix = qs.toString() ? `?${qs}` : '';
return request('GET', `/topics${suffix}`, { signal: filter.signal });
const r = await request<{ topics: Topic[] | null; count: number }>(
'GET',
`/topics${suffix}`,
{ signal: filter.signal },
);
return { topics: r.topics ?? [], count: r.count ?? 0 };
}
export function getTopic(
export async function getTopic(
id: string,
signal?: AbortSignal,
): Promise<TopicDetail> {
return request('GET', `/topics/${encodeURIComponent(id)}`, { signal });
const t = await request<TopicDetail>('GET', `/topics/${encodeURIComponent(id)}`, { signal });
// camps is nullable pre-allocation; keep null vs [] meaningful — null
// means "not yet allocated" (UI shows '—'), [] would mean "allocated
// but no rows" (impossible by allocator contract, but distinct).
return t;
}
export function listArguments(
export async function listArguments(
topicId: string,
signal?: AbortSignal,
): Promise<{ arguments: Argument[]; count: number }> {
return request('GET', `/topics/${encodeURIComponent(topicId)}/arguments`, {
signal,
});
const r = await request<{ arguments: Argument[] | null; count: number }>(
'GET',
`/topics/${encodeURIComponent(topicId)}/arguments`,
{ signal },
);
return { arguments: r.arguments ?? [], count: r.count ?? 0 };
}
export function getVerdict(
@@ -112,14 +129,16 @@ export function getVerdict(
// --------------------------------------------------------------------
// Admin
export function getAgentSummary(
export async function getAgentSummary(
agentId: string,
signal?: AbortSignal,
): Promise<AgentSummary> {
return request('GET', `/admin/agents/${encodeURIComponent(agentId)}`, {
admin: true,
signal,
});
const r = await request<AgentSummary & { recent_topics: AgentSummary['recent_topics'] | null }>(
'GET',
`/admin/agents/${encodeURIComponent(agentId)}`,
{ admin: true, signal },
);
return { ...r, recent_topics: r.recent_topics ?? [] };
}
export { HttpError };