Skip to content

Commit 9ed9823

Browse files
committed
refactor(policy): update policy details and AI assistant components for improved functionality
1 parent ff6f4fa commit 9ed9823

File tree

10 files changed

+293
-162
lines changed

10 files changed

+293
-162
lines changed

apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,16 @@ import '@comp/ui/editor.css';
1010
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs';
1111
import type { PolicyDisplayFormat } from '@db';
1212
import type { JSONContent } from '@tiptap/react';
13-
import {
14-
DefaultChatTransport,
15-
getToolName,
16-
isToolUIPart,
17-
type ToolUIPart,
18-
type UIMessage,
19-
} from 'ai';
13+
import { DefaultChatTransport } from 'ai';
2014
import { structuredPatch } from 'diff';
2115
import { CheckCircle, Loader2, Sparkles, X } from 'lucide-react';
2216
import { useAction } from 'next-safe-action/hooks';
23-
import { useFeatureFlagEnabled } from 'posthog-js/react';
24-
import { useMemo, useState } from 'react';
17+
import { useCallback, useMemo, useRef, useState } from 'react';
2518
import { toast } from 'sonner';
2619
import { switchPolicyDisplayFormatAction } from '../../actions/switch-policy-display-format';
2720
import { PdfViewer } from '../../components/PdfViewer';
2821
import { updatePolicy } from '../actions/update-policy';
22+
import type { PolicyChatUIMessage } from '../types';
2923
import { markdownToTipTapJSON } from './ai/markdown-utils';
3024
import { PolicyAiAssistant } from './ai/policy-ai-assistant';
3125

@@ -49,24 +43,32 @@ interface LatestProposal {
4943
key: string;
5044
content: string;
5145
summary: string;
46+
title: string;
47+
detail: string;
48+
reviewHint: string;
5249
}
5350

54-
function getLatestProposedPolicy(messages: UIMessage[]): LatestProposal | null {
51+
function getLatestProposedPolicy(messages: PolicyChatUIMessage[]): LatestProposal | null {
5552
const lastAssistantMessage = [...messages].reverse().find((m) => m.role === 'assistant');
5653
if (!lastAssistantMessage?.parts) return null;
5754

5855
let latest: LatestProposal | null = null;
5956

6057
lastAssistantMessage.parts.forEach((part, index) => {
61-
if (!isToolUIPart(part) || getToolName(part) !== 'proposePolicy') return;
62-
const toolPart = part as ToolUIPart;
63-
const input = toolPart.input as { content?: string; summary?: string } | undefined;
58+
if (part.type !== 'tool-proposePolicy') return;
59+
if (part.state === 'input-streaming' || part.state === 'output-error') return;
60+
const input = part.input;
6461
if (!input?.content) return;
6562

6663
latest = {
6764
key: `${lastAssistantMessage.id}:${index}`,
6865
content: input.content,
6966
summary: input.summary ?? 'Proposing policy changes',
67+
title: input.title ?? input.summary ?? 'Policy updates ready for your review',
68+
detail:
69+
input.detail ??
70+
'I have prepared an updated version of this policy based on your instructions.',
71+
reviewHint: input.reviewHint ?? 'Review the proposed changes below before applying them.',
7072
};
7173
});
7274

@@ -100,13 +102,18 @@ export function PolicyContentManager({
100102
const [dismissedProposalKey, setDismissedProposalKey] = useState<string | null>(null);
101103
const [isApplying, setIsApplying] = useState(false);
102104
const [chatErrorMessage, setChatErrorMessage] = useState<string | null>(null);
103-
const isAiPolicyAssistantEnabled = useFeatureFlagEnabled('is-ai-policy-assistant-enabled');
105+
const diffViewerRef = useRef<HTMLDivElement>(null);
106+
107+
const isAiPolicyAssistantEnabled = true;
108+
const scrollToDiffViewer = useCallback(() => {
109+
diffViewerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
110+
}, []);
104111

105112
const {
106113
messages,
107114
status,
108115
sendMessage: baseSendMessage,
109-
} = useChat({
116+
} = useChat<PolicyChatUIMessage>({
110117
transport: new DefaultChatTransport({
111118
api: `/api/policies/${policyId}/chat`,
112119
}),
@@ -128,6 +135,20 @@ export function PolicyContentManager({
128135

129136
const proposedPolicyMarkdown = activeProposal?.content ?? null;
130137

138+
const hasPendingProposal = useMemo(
139+
() =>
140+
messages.some(
141+
(m) =>
142+
m.role === 'assistant' &&
143+
m.parts?.some(
144+
(part) =>
145+
part.type === 'tool-proposePolicy' &&
146+
(part.state === 'input-streaming' || part.state === 'input-available'),
147+
),
148+
),
149+
[messages],
150+
);
151+
131152
const switchFormat = useAction(switchPolicyDisplayFormatAction, {
132153
onError: () => toast.error('Failed to switch view.'),
133154
});
@@ -226,15 +247,17 @@ export function PolicyContentManager({
226247
errorMessage={chatErrorMessage}
227248
sendMessage={sendMessage}
228249
close={() => setShowAiAssistant(false)}
250+
onScrollToDiff={scrollToDiffViewer}
251+
hasActiveProposal={!!activeProposal && !hasPendingProposal}
229252
/>
230253
</div>
231254
)}
232255
</div>
233256
</CardContent>
234257
</Card>
235258

236-
{proposedPolicyMarkdown && diffPatch && activeProposal && (
237-
<div className="space-y-2">
259+
{proposedPolicyMarkdown && diffPatch && activeProposal && !hasPendingProposal && (
260+
<div ref={diffViewerRef} className="space-y-2">
238261
<div className="flex items-center justify-end gap-2">
239262
<Button
240263
variant="ghost"
@@ -261,7 +284,7 @@ export function PolicyContentManager({
261284
}
262285

263286
function createGitPatch(fileName: string, oldStr: string, newStr: string): string {
264-
const patch = structuredPatch(fileName, fileName, oldStr, newStr);
287+
const patch = structuredPatch(fileName, fileName, oldStr, newStr, '', '', { context: 1 });
265288
const lines: string[] = [
266289
`diff --git a/${fileName} b/${fileName}`,
267290
`--- a/${fileName}`,

apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,29 @@ import {
1212
PromptInputTextarea,
1313
} from '@comp/ui/ai-elements/prompt-input';
1414
import { Tool, ToolHeader } from '@comp/ui/ai-elements/tool';
15+
import { Badge } from '@comp/ui/badge';
1516
import { Button } from '@comp/ui/button';
1617
import { cn } from '@comp/ui/cn';
17-
import { getToolName, isToolUIPart, type ChatStatus, type ToolUIPart, type UIMessage } from 'ai';
18-
import { X } from 'lucide-react';
18+
import type { ChatStatus } from 'ai';
19+
import {
20+
ArrowDownIcon,
21+
CheckCircleIcon,
22+
CircleIcon,
23+
ClockIcon,
24+
X,
25+
XCircleIcon,
26+
} from 'lucide-react';
1927
import { useState } from 'react';
28+
import type { PolicyChatUIMessage } from '../../types';
2029

2130
interface PolicyAiAssistantProps {
22-
messages: UIMessage[];
31+
messages: PolicyChatUIMessage[];
2332
status: ChatStatus;
2433
errorMessage?: string | null;
2534
sendMessage: (payload: { text: string }) => void;
2635
close?: () => void;
36+
onScrollToDiff?: () => void;
37+
hasActiveProposal?: boolean;
2738
}
2839

2940
export function PolicyAiAssistant({
@@ -32,6 +43,8 @@ export function PolicyAiAssistant({
3243
errorMessage,
3344
sendMessage,
3445
close,
46+
onScrollToDiff,
47+
hasActiveProposal,
3548
}: PolicyAiAssistantProps) {
3649
const [input, setInput] = useState('');
3750

@@ -41,7 +54,11 @@ export function PolicyAiAssistant({
4154
(m) =>
4255
m.role === 'assistant' &&
4356
m.parts.some(
44-
(p) => isToolUIPart(p) && (p.state === 'input-streaming' || p.state === 'input-available'),
57+
(p) =>
58+
p.type === 'tool-proposePolicy' &&
59+
(p.state === 'input-streaming' ||
60+
p.state === 'output-available' ||
61+
p.state === 'output-error'),
4562
),
4663
);
4764

@@ -93,18 +110,92 @@ export function PolicyAiAssistant({
93110
);
94111
}
95112

96-
if (isToolUIPart(part) && getToolName(part) === 'proposePolicy') {
97-
const toolPart = part as ToolUIPart;
98-
const toolInput = toolPart.input as { content?: string; summary?: string };
113+
if (part.type === 'tool-proposePolicy') {
114+
const toolInput = part.input;
115+
116+
const isInProgress =
117+
part.state === 'input-streaming' || part.state === 'input-available';
118+
const isCompleted = part.state === 'output-available';
119+
120+
const title =
121+
(isCompleted
122+
? toolInput?.title || toolInput?.summary
123+
: toolInput?.title || 'Configuring policy updates') ||
124+
'Policy updates ready for your review';
125+
126+
const bodyText = (() => {
127+
if (isInProgress) {
128+
return (
129+
toolInput?.detail ||
130+
'I am preparing an updated version of this policy. Please wait a moment before accepting any changes.'
131+
);
132+
}
133+
if (isCompleted) {
134+
return (
135+
toolInput?.detail ||
136+
'The updated policy is ready. Review the diff in the editor before applying changes.'
137+
);
138+
}
139+
return (
140+
toolInput?.detail ||
141+
'Review the proposed changes in the editor preview below before applying them.'
142+
);
143+
})();
144+
145+
const truncatedBodyText =
146+
bodyText.length > 180 ? `${bodyText.slice(0, 177)}…` : bodyText;
147+
148+
type ToolState = typeof part.state;
149+
const statusPill = (() => {
150+
const labels: Record<ToolState, string> = {
151+
'input-streaming': 'Drafting',
152+
'input-available': 'Running',
153+
'output-available': 'Completed',
154+
'output-error': 'Error',
155+
};
156+
157+
const icons: Record<ToolState, React.ReactNode> = {
158+
'input-streaming': <CircleIcon className="size-3" />,
159+
'input-available': <ClockIcon className="size-3 animate-pulse" />,
160+
'output-available': <CheckCircleIcon className="size-3 text-emerald-600" />,
161+
'output-error': <XCircleIcon className="size-3 text-red-600" />,
162+
};
163+
164+
return (
165+
<Badge
166+
className="gap-1.5 rounded-full border border-border/60 bg-background/80 px-2 py-0.5 text-[10px] uppercase tracking-[0.16em]"
167+
variant="secondary"
168+
>
169+
{icons[part.state]}
170+
{labels[part.state]}
171+
</Badge>
172+
);
173+
})();
174+
99175
return (
100176
<Tool key={`${message.id}-${index}`} className="mt-2">
101177
<ToolHeader
102-
title={toolInput?.summary || 'Proposing policy changes'}
103-
type={toolPart.type}
104-
state={toolPart.state}
178+
title={title}
179+
meta={statusPill}
180+
onClick={isCompleted && onScrollToDiff ? onScrollToDiff : undefined}
181+
className={
182+
isCompleted && onScrollToDiff
183+
? 'cursor-pointer hover:bg-muted/50'
184+
: undefined
185+
}
105186
/>
106-
<p className="px-3 pb-2 text-xs text-muted-foreground">
107-
View the proposed changes in the editor preview
187+
<p className="px-3 py-2 text-[10px] text-muted-foreground">
188+
{truncatedBodyText}
189+
{hasActiveProposal && onScrollToDiff && (
190+
<button
191+
type="button"
192+
onClick={onScrollToDiff}
193+
className="flex items-center gap-1.5 text-[11px] text-primary hover:underline"
194+
>
195+
<ArrowDownIcon className="size-3" />
196+
View proposed changes
197+
</button>
198+
)}
108199
</p>
109200
</Tool>
110201
);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { type InferUITools, tool } from 'ai';
2+
import { z } from 'zod';
3+
4+
export function getPolicyTools() {
5+
return {
6+
proposePolicy: tool({
7+
description:
8+
'Propose an updated version of the policy. Use this tool whenever the user asks you to make changes, edits, or improvements to the policy. You must provide the COMPLETE policy content, not just the changes.',
9+
inputSchema: z.object({
10+
content: z
11+
.string()
12+
.describe(
13+
'The complete updated policy content in markdown format. Must include the entire policy, not just the changed sections.',
14+
),
15+
summary: z
16+
.string()
17+
.describe('One to two sentences summarizing the changes. No bullet points.'),
18+
title: z
19+
.string()
20+
.describe(
21+
'A short, sentence-case heading (~4–10 words) that clearly states the main change, for use in a small review banner.',
22+
),
23+
detail: z
24+
.string()
25+
.describe(
26+
'One or two plain-text sentences briefly explaining what changed and why, shown in the review banner.',
27+
),
28+
reviewHint: z
29+
.string()
30+
.describe(
31+
'A very short imperative phrase that tells the user to review the updated policy content in the editor below.',
32+
),
33+
}),
34+
execute: async ({ summary, title, detail, reviewHint }) => ({
35+
success: true,
36+
summary,
37+
title,
38+
detail,
39+
reviewHint,
40+
}),
41+
}),
42+
};
43+
}
44+
45+
export type PolicyToolSet = InferUITools<ReturnType<typeof getPolicyTools>>;

apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import type { UIMessage } from 'ai';
12
import { z } from 'zod';
3+
import type { PolicyToolSet } from '../tools/policy-tools';
4+
5+
export type PolicyChatUIMessage = UIMessage<never, never, PolicyToolSet>;
26

37
export const policyDetailsSchema = z.object({
48
id: z.string(),

0 commit comments

Comments
 (0)