Skip to content

Commit fb10f81

Browse files
Share permission option presentation logic
Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f
1 parent 9a43b94 commit fb10f81

5 files changed

Lines changed: 143 additions & 61 deletions

File tree

apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import {
2+
getPermissionOptionMeta,
3+
isPermissionRejection,
4+
permissionOptionUsesCustomInput,
5+
} from "@posthog/core/sessions/permissionResponse";
6+
import { extractPlanText } from "@posthog/core/sessions/planApprovalPresentation";
17
import {
28
ArrowsClockwise,
39
ChatCircle,
@@ -28,22 +34,6 @@ interface PlanApprovalCardProps {
2834
onSendPermissionResponse?: (args: PermissionResponseArgs) => void;
2935
}
3036

31-
function optionMeta(option: CloudPendingPermissionRequest["options"][number]) {
32-
return option._meta as
33-
| {
34-
customInput?: boolean;
35-
description?: string;
36-
}
37-
| undefined;
38-
}
39-
40-
function isRejectOption(
41-
option?: CloudPendingPermissionRequest["options"][number],
42-
) {
43-
if (!option) return false;
44-
return option.kind.startsWith("reject") || option.optionId.includes("reject");
45-
}
46-
4737
export function PlanApprovalCard({
4838
toolData,
4939
permission,
@@ -101,7 +91,9 @@ export function PlanApprovalCard({
10191
selectedOption?.name ||
10292
response?.displayText ||
10393
null;
104-
const resolvedAsReject = isRejectOption(selectedOption);
94+
const resolvedAsReject = selectedOption
95+
? isPermissionRejection(selectedOption)
96+
: false;
10597

10698
return (
10799
<View className="mx-4 my-1 rounded-lg border border-accent-6 bg-gray-2">
@@ -165,8 +157,8 @@ export function PlanApprovalCard({
165157
) : (
166158
<View className="px-3 pb-3">
167159
{permission.options.map((option) => {
168-
const meta = optionMeta(option);
169-
const usesCustomInput = meta?.customInput === true;
160+
const meta = getPermissionOptionMeta(option);
161+
const usesCustomInput = permissionOptionUsesCustomInput(option);
170162
const isCustomSelected = selectedCustomOptionId === option.optionId;
171163

172164
return (
@@ -194,7 +186,7 @@ export function PlanApprovalCard({
194186
>
195187
{option.name}
196188
</Text>
197-
{meta?.description && (
189+
{meta.description && (
198190
<Text className="mt-1 font-mono text-[11px] text-gray-9 leading-4">
199191
{meta.description}
200192
</Text>
@@ -239,5 +231,3 @@ export function PlanApprovalCard({
239231
</View>
240232
);
241233
}
242-
243-
import { extractPlanText } from "@posthog/core/sessions/planApprovalPresentation";

packages/core/src/sessions/permissionResponse.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,62 @@ import type { PermissionRequest } from "@posthog/shared";
22
import { describe, expect, it } from "vitest";
33
import {
44
formatPermissionAnswerPrompt,
5+
getPermissionOptionMeta,
56
isOtherPermissionOption,
7+
isPermissionApproval,
8+
isPermissionRejection,
9+
permissionOptionUsesCustomInput,
610
planPermissionResponse,
11+
resolveInitialPlanApprovalOption,
12+
selectPlanPermissionOptions,
713
} from "./permissionResponse";
814

15+
describe("permission option presentation", () => {
16+
const approveOnce = {
17+
optionId: "default",
18+
name: "Approve",
19+
kind: "allow_once" as const,
20+
};
21+
const approveAuto = {
22+
optionId: "auto",
23+
name: "Approve automatically",
24+
kind: "allow_always" as const,
25+
};
26+
const reject = {
27+
optionId: "reject_with_feedback",
28+
name: "Reject",
29+
kind: "reject_once" as const,
30+
_meta: { customInput: true, description: "Explain why" },
31+
};
32+
33+
it("classifies approval, rejection, and custom-input options", () => {
34+
expect(isPermissionApproval(approveOnce)).toBe(true);
35+
expect(isPermissionRejection(reject)).toBe(true);
36+
expect(permissionOptionUsesCustomInput(reject)).toBe(true);
37+
expect(getPermissionOptionMeta(reject)).toEqual({
38+
customInput: true,
39+
description: "Explain why",
40+
});
41+
});
42+
43+
it("selects plan options and prefers a feedback rejection", () => {
44+
expect(selectPlanPermissionOptions([approveOnce, reject])).toEqual({
45+
approvals: [approveOnce],
46+
rejection: reject,
47+
});
48+
});
49+
50+
it.each([
51+
["default", "default"],
52+
[null, "auto"],
53+
["missing", "auto"],
54+
])("resolves preferred approval %s", (preferred, expected) => {
55+
expect(
56+
resolveInitialPlanApprovalOption([approveOnce, approveAuto], preferred),
57+
).toBe(expected);
58+
});
59+
});
60+
961
function makePermission(
1062
options: Array<{
1163
optionId: string;

packages/core/src/sessions/permissionResponse.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,72 @@
11
import type { PermissionRequest } from "@posthog/shared";
22

3+
export type PermissionOption = PermissionRequest["options"][number];
4+
5+
export function getPermissionOptionMeta(option: PermissionOption): {
6+
customInput: boolean;
7+
description?: string;
8+
} {
9+
const meta = option._meta as
10+
| { customInput?: boolean; description?: string }
11+
| null
12+
| undefined;
13+
return {
14+
customInput: meta?.customInput === true,
15+
...(meta?.description ? { description: meta.description } : {}),
16+
};
17+
}
18+
19+
export function isPermissionApproval(option: PermissionOption): boolean {
20+
return option.kind === "allow_once" || option.kind === "allow_always";
21+
}
22+
23+
export function isPermissionRejection(option: PermissionOption): boolean {
24+
return (
25+
option.kind === "reject_once" ||
26+
option.kind === "reject_always" ||
27+
option.optionId.includes("reject")
28+
);
29+
}
30+
31+
export function permissionOptionUsesCustomInput(
32+
option: PermissionOption,
33+
): boolean {
34+
return (
35+
isOtherPermissionOption(option.optionId) ||
36+
getPermissionOptionMeta(option).customInput
37+
);
38+
}
39+
40+
export function selectPlanPermissionOptions(options: PermissionOption[]): {
41+
approvals: PermissionOption[];
42+
rejection: PermissionOption | null;
43+
} {
44+
const approvals = options.filter(isPermissionApproval);
45+
const rejections = options.filter(isPermissionRejection);
46+
return {
47+
approvals,
48+
rejection:
49+
rejections.find(permissionOptionUsesCustomInput) ?? rejections[0] ?? null,
50+
};
51+
}
52+
53+
export function resolveInitialPlanApprovalOption(
54+
approvals: PermissionOption[],
55+
preferredOptionId?: string | null,
56+
): string | undefined {
57+
const has = (optionId: string): boolean =>
58+
approvals.some((option) => option.optionId === optionId);
59+
return (
60+
(preferredOptionId && has(preferredOptionId)
61+
? preferredOptionId
62+
: undefined) ??
63+
(has("auto") ? "auto" : undefined) ??
64+
approvals.find((option) => option.optionId === "default")?.optionId ??
65+
approvals.find((option) => option.kind === "allow_once")?.optionId ??
66+
approvals[0]?.optionId
67+
);
68+
}
69+
370
const OTHER_OPTION_ID = "_other";
471
const OTHER_OPTION_ID_ALT = "other";
572

packages/ui/src/features/permissions/PlanApprovalSelector.tsx

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type {
2-
PermissionOption,
3-
SessionConfigOption,
4-
} from "@agentclientprotocol/sdk";
1+
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
2+
import {
3+
resolveInitialPlanApprovalOption,
4+
selectPlanPermissionOptions,
5+
} from "@posthog/core/sessions/permissionResponse";
56
import type { ExecutionMode } from "@posthog/shared";
67
import { ModeSelector } from "@posthog/ui/features/message-editor/components/ModeSelector";
78
import { MODE_LABELS } from "@posthog/ui/features/sessions/modeStyles";
@@ -17,21 +18,6 @@ import { type BasePermissionProps, toSelectorOptions } from "./types";
1718
const TITLE = "Implementation Plan";
1819
const QUESTION = "Approve this plan to proceed?";
1920

20-
function isApprove(option: PermissionOption): boolean {
21-
return option.kind === "allow_once" || option.kind === "allow_always";
22-
}
23-
24-
function isReject(option: PermissionOption): boolean {
25-
return option.kind === "reject_once" || option.kind === "reject_always";
26-
}
27-
28-
function hasCustomInput(option: PermissionOption): boolean {
29-
return (
30-
(option._meta as { customInput?: boolean } | null | undefined)
31-
?.customInput === true
32-
);
33-
}
34-
3521
// Don't steal focus from an interactive element in a different grid cell
3622
// (multi-task view). Mirrors the guard in useActionSelectorState.
3723
function isInteractiveElementInDifferentCell(
@@ -65,11 +51,8 @@ export function PlanApprovalSelector({
6551
onSelect,
6652
onCancel,
6753
}: BasePermissionProps) {
68-
const approveOptions = useMemo(() => options.filter(isApprove), [options]);
69-
const rejectOption = useMemo(
70-
() =>
71-
options.find((o) => isReject(o) && hasCustomInput(o)) ??
72-
options.find(isReject),
54+
const { approvals: approveOptions, rejection: rejectOption } = useMemo(
55+
() => selectPlanPermissionOptions(options),
7356
[options],
7457
);
7558

@@ -81,16 +64,7 @@ export function PlanApprovalSelector({
8164
// Resolution order: the mode last approved with (remembered preference),
8265
// then "auto", then manual-approve, then any single-use mode, then the first.
8366
const initialMode = useMemo(() => {
84-
const has = (id: string) => approveOptions.some((o) => o.optionId === id);
85-
return (
86-
(lastApprovalMode && has(lastApprovalMode)
87-
? lastApprovalMode
88-
: undefined) ??
89-
(has("auto") ? "auto" : undefined) ??
90-
approveOptions.find((o) => o.optionId === "default")?.optionId ??
91-
approveOptions.find((o) => o.kind === "allow_once")?.optionId ??
92-
approveOptions[0]?.optionId
93-
);
67+
return resolveInitialPlanApprovalOption(approveOptions, lastApprovalMode);
9468
}, [approveOptions, lastApprovalMode]);
9569

9670
const [selectedMode, setSelectedMode] = useState(initialMode);

packages/ui/src/features/permissions/types.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
RequestPermissionRequest,
44
ToolCallContent,
55
} from "@agentclientprotocol/sdk";
6+
import { getPermissionOptionMeta } from "@posthog/core/sessions/permissionResponse";
67
import type { CodeToolKind } from "@posthog/ui/features/sessions/types";
78
import type { SelectorOption } from "@posthog/ui/primitives/ActionSelector";
89

@@ -26,14 +27,12 @@ export function toSelectorOptions(
2627
options: PermissionOption[],
2728
): SelectorOption[] {
2829
return options.map((opt) => {
29-
const meta = opt._meta as
30-
| { description?: string; customInput?: boolean }
31-
| undefined;
30+
const meta = getPermissionOptionMeta(opt);
3231
return {
3332
id: opt.optionId,
3433
label: opt.name,
35-
description: meta?.description,
36-
customInput: meta?.customInput,
34+
description: meta.description,
35+
customInput: meta.customInput,
3736
};
3837
});
3938
}

0 commit comments

Comments
 (0)