Skip to content

Commit f82eb5c

Browse files
authored
feat: signals ui improvements (#915)
### TL;DR Improved error handling and UI for the Inbox Signals feature, adding robust payload validation and better error states. ### What changed? - Added robust error handling and payload validation for signal report artefacts - Improved UI for loading, error, and empty states in the Inbox Signals tab - Added specific error messages for different failure scenarios (permissions, not found, invalid payload) - Created a developer console tool for local testing of different Inbox states - Enhanced the UI with better truncation, loading skeletons, and responsive design - Added type for `unavailableReason` to the `SignalReportArtefactsResponse` interface
1 parent db64a50 commit f82eb5c

File tree

6 files changed

+576
-50
lines changed

6 files changed

+576
-50
lines changed

apps/twig/src/api/posthogClient.ts

Lines changed: 132 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { logger } from "@renderer/lib/logger";
22
import type {
33
RepoAutonomyStatus,
4+
SignalReportArtefact,
45
SignalReportArtefactsResponse,
56
SignalReportsResponse,
67
Task,
@@ -12,6 +13,87 @@ import { createApiClient, type Schemas } from "./generated";
1213

1314
const log = logger.scope("posthog-client");
1415

16+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
17+
return typeof value === "object" && value !== null;
18+
}
19+
20+
function optionalString(value: unknown): string | null {
21+
return typeof value === "string" ? value : null;
22+
}
23+
24+
function normalizeSignalReportArtefact(
25+
value: unknown,
26+
): SignalReportArtefact | null {
27+
if (!isObjectRecord(value)) {
28+
return null;
29+
}
30+
31+
const id = optionalString(value.id);
32+
if (!id) {
33+
return null;
34+
}
35+
36+
const contentValue = isObjectRecord(value.content) ? value.content : null;
37+
if (!contentValue) {
38+
return null;
39+
}
40+
41+
const content = optionalString(contentValue.content);
42+
const sessionId = optionalString(contentValue.session_id);
43+
44+
// The backend may return empty content objects when binary decode fails.
45+
if (!content && !sessionId) {
46+
return null;
47+
}
48+
49+
return {
50+
id,
51+
type: optionalString(value.type) ?? "unknown",
52+
created_at: optionalString(value.created_at) ?? new Date(0).toISOString(),
53+
content: {
54+
session_id: sessionId ?? "",
55+
start_time: optionalString(contentValue.start_time) ?? "",
56+
end_time: optionalString(contentValue.end_time) ?? "",
57+
distinct_id: optionalString(contentValue.distinct_id) ?? "",
58+
content: content ?? "",
59+
distance_to_centroid:
60+
typeof contentValue.distance_to_centroid === "number"
61+
? contentValue.distance_to_centroid
62+
: null,
63+
},
64+
};
65+
}
66+
67+
function parseSignalReportArtefactsPayload(
68+
value: unknown,
69+
): SignalReportArtefactsResponse {
70+
const payload = isObjectRecord(value) ? value : null;
71+
const rawResults = Array.isArray(payload?.results)
72+
? payload.results
73+
: Array.isArray(value)
74+
? value
75+
: [];
76+
77+
const results = rawResults
78+
.map(normalizeSignalReportArtefact)
79+
.filter((artefact): artefact is SignalReportArtefact => artefact !== null);
80+
const count =
81+
typeof payload?.count === "number" ? payload.count : results.length;
82+
83+
if (rawResults.length > 0 && results.length === 0) {
84+
return {
85+
results: [],
86+
count: 0,
87+
unavailableReason: "invalid_payload",
88+
};
89+
}
90+
91+
return {
92+
results,
93+
count,
94+
};
95+
}
96+
1597
export class PostHogAPIClient {
1698
private api: ReturnType<typeof createApiClient>;
1799
private _teamId: number | null = null;
@@ -551,23 +633,58 @@ export class PostHogAPIClient {
551633
const url = new URL(
552634
`${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/artefacts/`,
553635
);
554-
const response = await this.api.fetcher.fetch({
555-
method: "get",
556-
url,
557-
path: `/api/projects/${teamId}/signal_reports/${reportId}/artefacts/`,
558-
});
636+
const path = `/api/projects/${teamId}/signal_reports/${reportId}/artefacts/`;
559637

560-
if (!response.ok) {
561-
throw new Error(
562-
`Failed to fetch signal report artefacts: ${response.statusText}`,
563-
);
564-
}
638+
try {
639+
const response = await this.api.fetcher.fetch({
640+
method: "get",
641+
url,
642+
path,
643+
});
565644

566-
const data = await response.json();
567-
return {
568-
results: data.results ?? data ?? [],
569-
count: data.count ?? data.results?.length ?? data?.length ?? 0,
570-
};
645+
if (!response.ok) {
646+
const responseText = await response.text();
647+
const unavailableReason =
648+
response.status === 403
649+
? "forbidden"
650+
: response.status === 404
651+
? "not_found"
652+
: "request_failed";
653+
654+
log.warn("Signal report artefacts unavailable", {
655+
teamId,
656+
reportId,
657+
status: response.status,
658+
statusText: response.statusText,
659+
body: responseText || undefined,
660+
});
661+
662+
return { results: [], count: 0, unavailableReason };
663+
}
664+
665+
const data = (await response.json()) as unknown;
666+
const parsed = parseSignalReportArtefactsPayload(data);
667+
668+
if (parsed.unavailableReason) {
669+
log.warn("Signal report artefacts payload did not match schema", {
670+
teamId,
671+
reportId,
672+
});
673+
}
674+
675+
return parsed;
676+
} catch (error) {
677+
log.warn("Failed to fetch signal report artefacts", {
678+
teamId,
679+
reportId,
680+
error,
681+
});
682+
return {
683+
results: [],
684+
count: 0,
685+
unavailableReason: "request_failed",
686+
};
687+
}
571688
}
572689

573690
async getRepositoryReadiness(

apps/twig/src/renderer/App.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ function App() {
3434
return initializeConnectivityStore();
3535
}, []);
3636

37+
// Dev-only inbox demo command for local QA from the renderer console.
38+
useEffect(() => {
39+
if (import.meta.env.PROD) {
40+
return;
41+
}
42+
43+
void import("@features/inbox/devtools/inboxDemoConsole").then(
44+
({ registerInboxDemoConsoleCommand }) => {
45+
registerInboxDemoConsoleCommand();
46+
},
47+
);
48+
}, []);
49+
3750
// Global workspace error listener for toasts
3851
useEffect(() => {
3952
const subscription = trpcVanilla.workspace.onError.subscribe(undefined, {

0 commit comments

Comments
 (0)