From 7baf39d6591f051a23b026b573b70cc1df7c9982 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Wed, 6 May 2026 20:35:06 +0200 Subject: [PATCH] feat(code): inbox toolbar, artefact parsing, and pending_input UX - Harden signal report artefact normalization (numeric ids, priority case, shape inference, video placeholder when decode fails, schema mismatch logs). - Collapse bulk actions into an overflow menu; reuse InboxBulkActionButton for Snooze/Suppress/Delete; destructive menu highlight styles. - Treat pending_input like ready for summary tooltip and list status badge; prefer suggested-reviewer ordering in API sort key. - Raise Tooltip z-index; inbox sidebar copy and count badge styling tweaks. --- apps/code/src/renderer/api/posthogClient.ts | 116 ++++++-- .../src/renderer/components/ui/Tooltip.tsx | 4 +- .../components/detail/ReportDetailPane.tsx | 2 +- .../inbox/components/list/SignalsToolbar.tsx | 262 ++++++++++++++---- .../components/utils/ReportCardContent.tsx | 4 +- .../features/inbox/utils/filterReports.ts | 2 +- .../sidebar/components/items/HomeItem.tsx | 15 +- apps/code/src/renderer/styles/globals.css | 11 + 8 files changed, 324 insertions(+), 92 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 1c1e10fb1..c74e9eb4d 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -257,6 +257,17 @@ function optionalString(value: unknown): string | null { return typeof value === "string" ? value : null; } +/** Accepts string ids; some serializers may emit other primitives in edge cases. */ +function optionalArtefactId(value: unknown): string | null { + if (typeof value === "string" && value.length > 0) { + return value; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return null; +} + type AnyArtefact = | SignalReportArtefact | PriorityJudgmentArtefact @@ -269,13 +280,17 @@ const PRIORITY_VALUES = new Set(["P0", "P1", "P2", "P3", "P4"]); function normalizePriorityJudgmentArtefact( value: Record, ): PriorityJudgmentArtefact | null { - const id = optionalString(value.id); + const id = optionalArtefactId(value.id); if (!id) return null; const contentValue = isObjectRecord(value.content) ? value.content : null; if (!contentValue) return null; - const priority = optionalString(contentValue.priority); + const rawPriority = optionalString(contentValue.priority); + const priority = + rawPriority && /^p[0-4]$/i.test(rawPriority) + ? rawPriority.toUpperCase() + : rawPriority; if (!priority || !PRIORITY_VALUES.has(priority)) return null; return { @@ -298,7 +313,7 @@ const ACTIONABILITY_VALUES = new Set([ function normalizeActionabilityJudgmentArtefact( value: Record, ): ActionabilityJudgmentArtefact | null { - const id = optionalString(value.id); + const id = optionalArtefactId(value.id); if (!id) return null; const contentValue = isObjectRecord(value.content) ? value.content : null; @@ -329,7 +344,7 @@ function normalizeActionabilityJudgmentArtefact( function normalizeSignalFindingArtefact( value: Record, ): SignalFindingArtefact | null { - const id = optionalString(value.id); + const id = optionalArtefactId(value.id); if (!id) return null; const contentValue = isObjectRecord(value.content) ? value.content : null; @@ -383,7 +398,27 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { return normalizePriorityJudgmentArtefact(value); } - const id = optionalString(value.id); + // Infer structured artefacts when `type` is missing or does not match the API + // (shape matches; enums are still validated inside each normalizer). + const contentForInfer = isObjectRecord(value.content) ? value.content : null; + if (contentForInfer) { + if (optionalString(contentForInfer.signal_id)) { + const inferredFinding = normalizeSignalFindingArtefact(value); + if (inferredFinding) { + return inferredFinding; + } + } + const inferredPriority = normalizePriorityJudgmentArtefact(value); + if (inferredPriority) { + return inferredPriority; + } + const inferredActionability = normalizeActionabilityJudgmentArtefact(value); + if (inferredActionability) { + return inferredActionability; + } + } + + const id = optionalArtefactId(value.id); if (!id) { return null; } @@ -393,13 +428,16 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { optionalString(value.created_at) ?? new Date(0).toISOString(); // suggested_reviewers: content is an array of reviewer objects - if (type === "suggested_reviewers" && Array.isArray(value.content)) { - return { - id, - type: "suggested_reviewers" as const, - created_at, - content: value.content as SuggestedReviewersArtefact["content"], - }; + if (type === "suggested_reviewers") { + if (Array.isArray(value.content)) { + return { + id, + type: "suggested_reviewers" as const, + created_at, + content: value.content as SuggestedReviewersArtefact["content"], + }; + } + return null; } // video_segment and other artefacts with object content @@ -413,7 +451,22 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { // The backend may return empty content objects when binary decode fails. if (!content && !sessionId) { - return null; + return { + id, + type, + created_at, + content: { + session_id: "", + start_time: optionalString(contentValue.start_time) ?? "", + end_time: optionalString(contentValue.end_time) ?? "", + distinct_id: optionalString(contentValue.distinct_id) ?? "", + content: "", + distance_to_centroid: + typeof contentValue.distance_to_centroid === "number" + ? contentValue.distance_to_centroid + : null, + }, + }; } return { @@ -436,6 +489,7 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { function parseSignalReportArtefactsPayload( value: unknown, + debugContext?: { teamId: number; reportId: string }, ): SignalReportArtefactsResponse { const payload = isObjectRecord(value) ? value : null; const rawResults = Array.isArray(payload?.results) @@ -451,6 +505,30 @@ function parseSignalReportArtefactsPayload( typeof payload?.count === "number" ? payload.count : results.length; if (rawResults.length > 0 && results.length === 0) { + if (debugContext) { + const sample = rawResults.slice(0, 5).map((item) => { + if (!isObjectRecord(item)) { + return { shape: typeof item }; + } + const t = optionalString(item.type); + const content = item.content; + return { + type: t ?? "(missing)", + idKind: typeof item.id, + contentKind: Array.isArray(content) + ? "array" + : isObjectRecord(content) + ? "object" + : typeof content, + }; + }); + log.warn("Signal report artefacts payload did not match schema", { + teamId: debugContext.teamId, + reportId: debugContext.reportId, + rawCount: rawResults.length, + sample: sample, + }); + } return { results: [], count: 0, @@ -1961,14 +2039,10 @@ export class PostHogAPIClient { } const data = (await response.json()) as unknown; - const parsed = parseSignalReportArtefactsPayload(data); - - if (parsed.unavailableReason) { - log.warn("Signal report artefacts payload did not match schema", { - teamId, - reportId, - }); - } + const parsed = parseSignalReportArtefactsPayload(data, { + teamId, + reportId, + }); return parsed; } catch (error) { diff --git a/apps/code/src/renderer/components/ui/Tooltip.tsx b/apps/code/src/renderer/components/ui/Tooltip.tsx index 351dbd537..c6bfa4a96 100644 --- a/apps/code/src/renderer/components/ui/Tooltip.tsx +++ b/apps/code/src/renderer/components/ui/Tooltip.tsx @@ -43,11 +43,11 @@ export function Tooltip({ side={side} align={align} sideOffset={sideOffset} - className="dark flex items-center gap-[8px] rounded-[6px] border border-(--gray-4) bg-(--gray-2) px-[10px] py-[6px] text-(--gray-12) text-xs leading-[1.4]" + className="dark z-[200000] flex items-center gap-[8px] rounded-[6px] border border-(--gray-4) bg-(--gray-2) px-[10px] py-[6px] text-(--gray-12) text-xs leading-[1.4]" style={{ whiteSpace: isSimpleContent ? "nowrap" : "normal", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)", - zIndex: 9999, + zIndex: 200000, animationDuration: "150ms", animationTimingFunction: "ease-out", willChange: "transform, opacity", diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 1a9510c2d..4af80793c 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -344,7 +344,7 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { )} {/* ── Description ─────────────────────────────────────── */} - {report.status !== "ready" ? ( + {report.status !== "ready" && report.status !== "pending_input" ? (
+ + {primary} + + + Disabled because {reason}. + + + ); + } + return primary; +} + +type InboxBulkActionButtonProps = Pick< + ButtonProps, + "tooltipContent" | "disabledReason" | "disabled" | "onClick" +> & { + color: NonNullable; + loading: boolean; + icon: ReactNode; + label: string; +}; + +function InboxBulkActionButton({ + color, + loading, + icon, + label, + tooltipContent, + disabledReason, + disabled, + onClick, +}: InboxBulkActionButtonProps) { + return ( + + ); +} + export function SignalsToolbar({ totalCount, filteredCount, @@ -94,6 +164,7 @@ export function SignalsToolbar({ const setSearchQuery = useInboxSignalsFilterStore((s) => s.setSearchQuery); const [showSuppressConfirm, setShowSuppressConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [moreActionsOpen, setMoreActionsOpen] = useState(false); const { selectedCount, @@ -269,68 +340,145 @@ export function SignalsToolbar({ - + } + /> + + {reingestDisabledReason !== null || isReingesting ? ( + + + + + {isReingesting ? ( + + ) : ( + + )} + Reingest + + + + + ) : ( + + { + void handleReingest(); + }} + > + + + Reingest + + + + )} + {deleteDisabledReason !== null || isDeleting ? ( + + + + + {isDeleting ? ( + + ) : ( + + )} + Delete + + + + + ) : ( + + setShowDeleteConfirm(true)} + > + + + Delete + + + + )} + + + } + label="Snooze" tooltipContent="Wait for this report to gather more context" disabledReason={snoozeDisabledReason} disabled={snoozeDisabledReason !== null || isSnoozing} onClick={() => void handleSnooze()} - > - {isSnoozing ? : } - Snooze - - - - {IS_DEV && ( - - )} + /> @@ -342,7 +490,7 @@ export function SignalsToolbar({ - + Suppress reports @@ -368,7 +516,7 @@ export function SignalsToolbar({ {isSuppressing ? ( ) : ( - + )} Suppress diff --git a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx b/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx index 0901323a7..ecf64aab5 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx +++ b/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx @@ -62,7 +62,9 @@ export function ReportCardContent({ className="min-w-0 flex-1" > {prependBadges} - {!isReady && } + {!(isReady || report.status === "pending_input") && ( + + )} 0 - ? `${signalCount} actionable report${signalCount === 1 ? "" : "s"} assigned to you` - : "No actionable reports assigned to you yet" + ? `${signalCount} auto pull request${signalCount === 1 ? "" : "s"} assigned to you` + : "No auto pull requests assigned to you yet" } shortcut={formatHotkey(SHORTCUTS.INBOX)} side="right" @@ -55,20 +55,17 @@ export function InboxItem({ isActive, onClick, signalCount }: InboxItemProps) { } label={ - <> - Inbox + + Inbox {signalCount && signalCount > 0 ? ( {formatSignalCount(signalCount)} ) : null} - + } isActive={isActive} onClick={onClick} diff --git a/apps/code/src/renderer/styles/globals.css b/apps/code/src/renderer/styles/globals.css index 77e4e5db5..0edf75609 100644 --- a/apps/code/src/renderer/styles/globals.css +++ b/apps/code/src/renderer/styles/globals.css @@ -1036,6 +1036,17 @@ button, color: var(--gray-12) !important; } +.rt-BaseMenuItem[data-highlighted][data-variant="destructive"], +[role="menuitem"][data-highlighted][data-variant="destructive"] { + background-color: var(--red-3) !important; + color: var(--red-11) !important; +} + +.rt-BaseMenuItem[data-highlighted][data-variant="destructive"] svg, +[role="menuitem"][data-highlighted][data-variant="destructive"] svg { + color: var(--red-11) !important; +} + /* Select/Menu dropdown background matches theme */ .rt-SelectContent, .rt-BaseMenuContent {