Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
1 change: 0 additions & 1 deletion apps/mobile/src/features/chat/utils/thinkingMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ export const THINKING_MESSAGES = [
"Blooming",
"Sparking",
"Nesting",
"Looping",
"Wiring",
"Snipping",
"Zoning",
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/inbox/engagement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ describe("buildBulkActionEvents", () => {
expect(events.every((e) => e.action_type === "delete")).toBe(true);
});

it("passes through the remove-suggested-reviewer action type", () => {
const events = buildBulkActionEvents({
reports: [fakeReport({ id: "a" }), fakeReport({ id: "b" })],
actionType: "remove_suggested_reviewer",
surface: "toolbar",
});

expect(
events.every((e) => e.action_type === "remove_suggested_reviewer"),
).toBe(true);
expect(events.every((e) => e.dismissal_reason === undefined)).toBe(true);
});

it("attaches dismissal reason/note only for dismiss, truncating the note", () => {
const longNote = "x".repeat(600);
const [dismissed] = buildBulkActionEvents({
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/inbox/engagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function resolveActionProperties(
/** Bulk-capable report actions fired from the selection toolbar / dismiss flows. */
export type InboxBulkActionType = Extract<
InboxReportActionProperties["action_type"],
"dismiss" | "snooze" | "delete" | "reingest"
"dismiss" | "snooze" | "delete" | "reingest" | "remove_suggested_reviewer"
>;

export interface BuildBulkActionEventsInput {
Expand All @@ -137,8 +137,9 @@ export interface BuildBulkActionEventsInput {

/**
* Build `INBOX_REPORT_ACTION` payloads for a bulk (or single-report) dismiss /
* snooze / delete / reingest. Pure so it can be unit-tested and reused across
* the toolbar, the per-row dismiss action, and detail-screen dismiss.
* snooze / delete / reingest / remove-suggested-reviewer. Pure so it can be
* unit-tested and reused across the toolbar, the per-row dismiss action, and
* detail-screen dismiss.
*
* `is_bulk` / `bulk_size` carry the grouping; `rank` / `list_size` are left at 0
* because these flows act on a selection, not a positional list slot.
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/sessions/titleGeneratorService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,42 @@ describe("generateTitleAndSummary", () => {
expect(result?.title).toBe("Fix login bug");
});

it("does not parse SUMMARY in a PR title as the summary", async () => {
prompt.mockResolvedValue({
content:
"TITLE: Review PR #123: Fix SUMMARY: parsing\nSUMMARY: Fixing title and summary parsing.",
});

const result = await makeService().generateTitleAndSummary(
'<github_pr number="123" title="Fix SUMMARY: parsing" url="https://github.com/org/repo/pull/123" />',
);

expect(result).toEqual({
title: "Review PR #123: Fix SUMMARY: parsing",
summary: "Fixing title and summary parsing.",
});
});

it("instructs the model to include existing GitHub PR titles", async () => {
prompt.mockResolvedValue({
content:
"TITLE: Review PR #123: Fix login redirect\nSUMMARY: Reviewing the existing pull request.",
});

await makeService().generateTitleAndSummary(
'<github_pr number="123" title="Fix login redirect" url="https://github.com/org/repo/pull/123" />',
);

expect(prompt).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
system: expect.stringContaining(
"the generated TITLE MUST include both the PR number and the PR title verbatim",
),
}),
);
});

it("returns null on error", async () => {
prompt.mockRejectedValue(new Error("network error"));
const result = await makeService().generateTitleAndSummary("some content");
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/sessions/titleGeneratorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Title rules:
- Remove: the, this, my, a, an
- If possible, start with action verbs (Fix, Implement, Analyze, Debug, Update, Research, Review)
- Keep exact: technical terms, numbers, filenames, HTTP codes, PR numbers
- GitHub PR rule: If the content contains a <github_pr> with a non-empty title, the generated TITLE MUST include both the PR number and the PR title verbatim. This rule overrides the 6-word title limit. Never replace the PR title with a generic phrase. Before responding, verify that both values appear in TITLE.
- Never assume tech stack
- Only output "Untitled" if the input is completely null/missing, not just unclear
- If the input is a URL (e.g. a GitHub issue link, PR link, or any web URL), generate a title based on what you can infer from the URL structure (repo name, issue/PR number, etc.). Never say you cannot access URLs or ask the user for more information.
Expand All @@ -58,6 +59,7 @@ Title examples:
- "Update user documentation for new API endpoints" → Update API documentation
- "Research competitor pricing strategies for our product" → Research competitor pricing
- "Review pull request #123" → Review pull request #123
- "<github_pr number="123" title="Fix login redirect" url="https://github.com/org/repo/pull/123" />" → Review PR #123: Fix login redirect
- "debug 500 errors in production" → Debug production 500 errors
- "why is the payment flow failing" → Analyze payment flow failure
- "So how about that weather huh" → Weather chat
Expand Down Expand Up @@ -173,7 +175,7 @@ export class TitleGeneratorService {

const text = result.content.trim();
const titleMatch = text.match(/^TITLE:\s*(.+?)(?:\n|$)/m);
const summaryMatch = text.match(/SUMMARY:\s*([\s\S]+)$/m);
const summaryMatch = text.match(/^SUMMARY:\s*([\s\S]+)$/m);

const title =
titleMatch?.[1]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
EyeSlashIcon,
PauseIcon,
TrashIcon,
UserMinusIcon,
XIcon,
} from "@phosphor-icons/react";
import { isDismissalReasonSnooze } from "@posthog/shared/dismissalReasons";
Expand Down Expand Up @@ -158,6 +159,24 @@ export function InboxBulkSelectionBar({
Reingest
</Button>

<Button
type="button"
size="1"
variant="soft"
color="gray"
tooltipContent="Remove yourself as a suggested reviewer on the selected reports"
disabledReason={bulkActions.removeReviewerDisabledReason}
disabled={
bulkActions.removeReviewerDisabledReason !== null ||
bulkActions.isRemovingReviewer
}
loading={bulkActions.isRemovingReviewer}
onClick={() => void bulkActions.removeReviewerSelected()}
>
<UserMinusIcon size={12} />
Remove me as reviewer
</Button>

<Button
type="button"
size="1"
Expand Down
131 changes: 128 additions & 3 deletions packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import {
import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation";
import type { InboxReportActionSurface } from "@posthog/shared/analytics-events";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import type { SignalReport } from "@posthog/shared/types";
import type {
SignalReport,
SuggestedReviewer,
SuggestedReviewersArtefact,
SuggestedReviewerWriteEntry,
} from "@posthog/shared/types";
import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
import type { DismissReportDialogResult } from "@posthog/ui/features/inbox/components/DismissReportDialog";
import { reportKeys } from "@posthog/ui/features/inbox/hooks/useInboxReports";
import { useInboxReportSelectionStore } from "@posthog/ui/features/inbox/stores/inboxReportSelectionStore";
Expand All @@ -15,7 +22,29 @@ import { track } from "@posthog/ui/shell/analytics";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";

type BulkActionName = "suppress" | "snooze" | "delete" | "reingest";
type BulkActionName =
| "suppress"
| "snooze"
| "delete"
| "reingest"
| "removeReviewer";

/**
* Map an enriched reviewer list back to the write shape the artefact PUT expects
* (mirrors `SuggestedReviewersSection`). The server takes the full replacement
* list, not a diff, so removing a reviewer means sending everyone else.
*/
function toReviewerWriteContent(
reviewers: SuggestedReviewer[],
): SuggestedReviewerWriteEntry[] {
return reviewers
.map((reviewer): SuggestedReviewerWriteEntry | null => {
if (reviewer.github_login) return { github_login: reviewer.github_login };
if (reviewer.user?.uuid) return { user_uuid: reviewer.user.uuid };
return null;
})
.filter((entry): entry is SuggestedReviewerWriteEntry => entry !== null);
}

interface BulkActionResult {
successCount: number;
Expand Down Expand Up @@ -54,6 +83,10 @@ const suppressibleStatuses = new Set<SignalReport["status"]>([
/** Clause after "Disabled because …" (see `@posthog/ui/primitives/Button`). */
const DISABLED_NO_SELECTION = "you haven't selected a report";

/** Clause when none of the selected reports have the current user as a reviewer. */
const DISABLED_NOT_A_REVIEWER =
"you aren't a suggested reviewer on any selected report";

/** Statuses that block suppression; labels match `inboxStatusLabel`. */
const SUPPRESS_BLOCKED_STATUS_PHRASE = (
["suppressed", "deleted"] as const satisfies readonly SignalReport["status"][]
Expand All @@ -69,6 +102,7 @@ type SelectedReportEligibility = {
suppressDisabledReason: string | null;
deleteDisabledReason: string | null;
reingestDisabledReason: string | null;
removeReviewerDisabledReason: string | null;
};

function formatBulkActionSummary(
Expand All @@ -84,7 +118,9 @@ function formatBulkActionSummary(
? `${pluralized} snoozed`
: action === "delete"
? `${pluralized} deleted`
: `${pluralized} reingested`;
: action === "reingest"
? `${pluralized} reingested`
: `${pluralized} · you're no longer a reviewer`;
if (failureCount === 0) {
return `${successCount} ${formulated}`;
}
Expand Down Expand Up @@ -130,6 +166,12 @@ function getSelectedReportEligibility(
suppressDisabledReason: snoozeOrSuppressDisabledReason,
deleteDisabledReason: selectedCount === 0 ? DISABLED_NO_SELECTION : null,
reingestDisabledReason: selectedCount === 0 ? DISABLED_NO_SELECTION : null,
removeReviewerDisabledReason:
selectedCount === 0
? DISABLED_NO_SELECTION
: selectedReports.some((report) => report.is_suggested_reviewer)
? null
: DISABLED_NOT_A_REVIEWER,
};
}

Expand Down Expand Up @@ -197,6 +239,9 @@ export function useInboxBulkActions(
surface: InboxReportActionSurface = "toolbar",
) {
const queryClient = useQueryClient();
const client = useOptionalAuthenticatedClient();
const { data: currentUser } = useCurrentUser({ client, enabled: !!client });
const meUuid = currentUser?.uuid;
const clearSelection = useInboxReportSelectionStore(
(state) => state.clearSelection,
);
Expand Down Expand Up @@ -380,6 +425,60 @@ export function useInboxBulkActions(
},
);

/**
* Remove the current user from each selected report's suggested reviewers.
* The artefact PUT replaces the whole list, so per report we fetch the latest
* `suggested_reviewers` artefact, drop the entry matching the current user's
* uuid, and write the rest back. Reports where the user isn't listed are a
* no-op (fetched only for reports the list already flags via
* `is_suggested_reviewer`).
*/
const removeReviewerMutation = useAuthenticatedMutation(
async (client, input: { reportIds: string[]; meUuid: string }) =>
runBulkAction(input.reportIds, async (reportId) => {
const artefacts = await client.getSignalReportArtefacts(reportId);
const artefact = artefacts.results.find(
(a): a is SuggestedReviewersArtefact =>
a.type === "suggested_reviewers",
);
if (!artefact) {
throw new Error("No suggested reviewers to update");
}
const next = artefact.content.filter(
(reviewer) => reviewer.user?.uuid !== input.meUuid,
);
// Throw rather than silently resolve when nothing changed: otherwise a
// no-op (user not present in the artefact) would be counted as a
// success by `runBulkAction`, firing a success toast/analytics and
// dropping the report from the selection while the reviewer remains.
if (next.length === artefact.content.length) {
throw new Error("Not a suggested reviewer on this report");
}
await client.updateSignalReportArtefact(
reportId,
artefact.id,
toReviewerWriteContent(next),
);
}),
{
onSuccess: async (result) => {
trackBulkAction("remove_suggested_reviewer", result);
await invalidateInboxQueries();
applyBulkResultToSelection(result);

if (result.failureCount > 0) {
toast.error(formatBulkActionSummary("removeReviewer", result));
return;
}

toast.success(formatBulkActionSummary("removeReviewer", result));
},
onError: (error) => {
toast.error(error.message || "Failed to remove yourself as reviewer");
},
},
);

const suppressSelected = useCallback(
async (dismissal?: DismissReportDialogResult) => {
if (eligibility.suppressDisabledReason !== null) {
Expand Down Expand Up @@ -438,20 +537,46 @@ export function useInboxBulkActions(
reingestMutation,
]);

const removeReviewerSelected = useCallback(async () => {
if (eligibility.removeReviewerDisabledReason !== null || !meUuid) {
return false;
}

const reportIds = eligibility.selectedReports
.filter((report) => report.is_suggested_reviewer)
.map((report) => report.id);
if (reportIds.length === 0) {
return false;
}

await removeReviewerMutation.mutateAsync({ reportIds, meUuid });
return true;
}, [
eligibility.removeReviewerDisabledReason,
eligibility.selectedReports,
meUuid,
removeReviewerMutation,
]);

return {
selectedReports: eligibility.selectedReports,
selectedCount: eligibility.selectedCount,
snoozeDisabledReason: eligibility.snoozeDisabledReason,
suppressDisabledReason: eligibility.suppressDisabledReason,
deleteDisabledReason: eligibility.deleteDisabledReason,
reingestDisabledReason: eligibility.reingestDisabledReason,
removeReviewerDisabledReason: meUuid
? eligibility.removeReviewerDisabledReason
: DISABLED_NOT_A_REVIEWER,
isSuppressing: suppressMutation.isPending,
isSnoozing: snoozeMutation.isPending,
isDeleting: deleteMutation.isPending,
isReingesting: reingestMutation.isPending,
isRemovingReviewer: removeReviewerMutation.isPending,
suppressSelected,
snoozeSelected,
deleteSelected,
reingestSelected,
removeReviewerSelected,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ const THINKING_MESSAGES = [
"Blooming",
"Sparking",
"Nesting",
"Looping",
"Wiring",
"Snipping",
"Zoning",
Expand Down
Loading