Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type { PolicyChatUIMessage } from '../types';
import { PolicyAiAssistant } from './ai/policy-ai-assistant';
import { useSuggestions } from '../hooks/use-suggestions';
import { buildPositionMap } from '../lib/build-position-map';
import { sanitizeMarkdown } from '../lib/policy-markdown';
import { resolveInitialPolicyContent } from '../lib/resolve-initial-content';
import { InlineEditBubble } from './ai/inline-edit-bubble';
import { markdownToTipTapJSON } from './ai/markdown-utils';
Expand Down Expand Up @@ -111,7 +112,9 @@ function getLatestCompletedProposal(messages: PolicyChatUIMessage[]): LatestProp

latest = {
key: `${msg.id}:${index}`,
content: input.content,
// Strip control-char noise (e.g. stray "013" glyphs) before the content
// feeds the diff + apply pipeline, so both see identical clean text.
content: sanitizeMarkdown(input.content),
summary: input.summary ?? 'Proposing policy changes',
title: input.title ?? input.summary ?? 'Policy updates ready for your review',
detail:
Expand Down Expand Up @@ -471,6 +474,7 @@ export function PolicyContentManager({
messages,
status,
sendMessage: baseSendMessage,
regenerate,
stop: stopChat,
} = useChat<PolicyChatUIMessage>({
transport: new DefaultChatTransport({
Expand All @@ -482,16 +486,28 @@ export function PolicyContentManager({
},
});

// The AI needs the live editor content (it may hold unsaved accepted changes),
// so every request — initial and retry — re-derives it from the current doc.
const buildChatBody = useCallback(
() => ({
currentContent: editorInstance
? buildPositionMap(editorInstance.state.doc).markdown
: '',
}),
[editorInstance],
);

const sendMessage = (payload: { text: string }) => {
setChatErrorMessage(null);
// Send current editor content so the AI sees the latest state,
// not stale DB content (e.g. after accepting changes)
const currentContent = editorInstance
? buildPositionMap(editorInstance.state.doc).markdown
: '';
baseSendMessage(payload, { body: { currentContent } });
baseSendMessage(payload, { body: buildChatBody() });
};

// Recover from an interrupted/incomplete run by re-running the last request.
const handleRetryChat = useCallback(() => {
setChatErrorMessage(null);
regenerate({ body: buildChatBody() });
}, [regenerate, buildChatBody]);

// ── Proposal state management ──────────────────────────────────────
// Scan ALL assistant messages for the latest completed proposePolicy tool call.
// Unlike before, this doesn't only check the last assistant message — it finds
Expand Down Expand Up @@ -902,6 +918,7 @@ export function PolicyContentManager({
errorMessage={chatErrorMessage}
sendMessage={sendMessage}
stop={stopChat}
retry={handleRetryChat}
close={() => setShowAiAssistant(false)}
/>
</div>
Expand Down Expand Up @@ -980,6 +997,7 @@ export function PolicyContentManager({
errorMessage={chatErrorMessage}
sendMessage={sendMessage}
stop={stopChat}
retry={handleRetryChat}
close={() => setShowAiAssistant(false)}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { JSONContent } from '@tiptap/react';
import { parseInline, sanitizeMarkdown } from '../../lib/policy-markdown';

export function markdownToTipTapJSON(markdown: string): Array<JSONContent> {
const lines = markdown.split('\n');
// Strip control-char noise (e.g. stray "013" glyphs) before parsing so it
// never reaches a ProseMirror text node.
const lines = sanitizeMarkdown(markdown).split('\n');
const content: Array<JSONContent> = [];
let currentList: JSONContent | null = null;
let listType: 'bulletList' | 'orderedList' | null = null;
Expand All @@ -28,7 +31,7 @@ export function markdownToTipTapJSON(markdown: string): Array<JSONContent> {
content.push({
type: 'heading',
attrs: { level: headingMatch[1].length },
content: [{ type: 'text', text: headingMatch[2] }],
content: parseInline(headingMatch[2]),
});
continue;
}
Expand All @@ -45,7 +48,7 @@ export function markdownToTipTapJSON(markdown: string): Array<JSONContent> {
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: bulletMatch[1] }],
content: parseInline(bulletMatch[1]),
},
],
});
Expand All @@ -64,7 +67,7 @@ export function markdownToTipTapJSON(markdown: string): Array<JSONContent> {
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: orderedMatch[1] }],
content: parseInline(orderedMatch[1]),
},
],
});
Expand All @@ -78,7 +81,7 @@ export function markdownToTipTapJSON(markdown: string): Array<JSONContent> {
}
content.push({
type: 'paragraph',
content: [{ type: 'text', text: trimmed }],
content: parseInline(trimmed),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import {
PromptInputSubmit,
} from '@/components/ai-elements/prompt-input';
import { Button } from '@trycompai/design-system';
import { Close, MagicWand } from '@trycompai/design-system/icons';
import { Close, MagicWand, Renew } from '@trycompai/design-system/icons';
import type { ChatStatus } from 'ai';
import { getProposalCardState } from '../../lib/proposal-card-state';
import type { PolicyChatUIMessage } from '../../types';

interface PolicyAiAssistantProps {
Expand All @@ -28,6 +29,8 @@ interface PolicyAiAssistantProps {
errorMessage?: string | null;
sendMessage: (payload: { text: string }) => void;
stop?: () => void;
/** Re-run the last request (used to recover from interrupted/incomplete runs). */
retry?: () => void;
close?: () => void;
}

Expand All @@ -37,6 +40,7 @@ export function PolicyAiAssistant({
errorMessage,
sendMessage,
stop,
retry,
close,
}: PolicyAiAssistantProps) {
const isBusy = status === 'streaming' || status === 'submitted';
Expand Down Expand Up @@ -99,11 +103,21 @@ export function PolicyAiAssistant({
}

if (part.type === 'tool-proposePolicy') {
// The proposed markdown lives in the tool INPUT. A
// truncated run can complete with empty content while
// the prose claims success (CS-256) — detect that here.
const proposed =
typeof part.input?.content === 'string'
? part.input.content
: '';
const hasContent = proposed.trim().length > 0;
return (
<PolicyToolCard
key={`${message.id}-${index}`}
state={part.state}
stopped={isMessageStopped}
hasContent={hasContent}
onRetry={isLastMessage ? retry : undefined}
/>
);
}
Expand Down Expand Up @@ -175,25 +189,28 @@ export function PolicyAiAssistant({
function PolicyToolCard({
state,
stopped,
hasContent,
onRetry,
}: {
state: string;
stopped: boolean;
hasContent: boolean;
onRetry?: () => void;
}) {
const isCompleted = state === 'output-available';
const isError = state === 'output-error';
const isWorking = !isCompleted && !isError;
const cardState = getProposalCardState(state, stopped, hasContent);

// Interrupted — streaming stopped while tool was in progress
if (isWorking && stopped) {
if (cardState === 'interrupted') {
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground/70">
<span>Interrupted</span>
</div>
<ToolFailureRow
message="The update was interrupted before it finished."
onRetry={onRetry}
/>
);
}

// Working state: same compact style as data tool cards
if (isWorking) {
if (cardState === 'working') {
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<svg
Expand All @@ -212,11 +229,23 @@ function PolicyToolCard({
}

// Error state
if (isError) {
if (cardState === 'error') {
return (
<p className="text-sm text-destructive">
Something went wrong while generating updates. Please try again.
</p>
<ToolFailureRow
message="Something went wrong while generating updates."
onRetry={onRetry}
/>
);
}

// Completed, but no content actually materialized — the run was truncated
// (timeout / output-token cap). Don't claim success on a blank policy (CS-256).
if (cardState === 'incomplete') {
return (
<ToolFailureRow
message="The update didn't finish writing, so no changes were applied."
onRetry={onRetry}
/>
);
}

Expand All @@ -232,6 +261,30 @@ function PolicyToolCard({
);
}

/**
* Compact failure row for the proposePolicy tool — interrupted, errored, or
* truncated-with-no-content. Offers a Retry affordance so a dead run isn't a
* permanent dead-end (the previous UI showed a static "Interrupted" forever).
*/
function ToolFailureRow({
message,
onRetry,
}: {
message: string;
onRetry?: () => void;
}) {
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{message}</span>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry} iconLeft={<Renew size={12} />}>
Retry
</Button>
)}
</div>
);
}

const TOOL_LABELS: Record<string, { loading: string; done: string }> = {
'tool-listVendors': { loading: 'Fetching vendors', done: 'Fetched vendors' },
'tool-getVendor': { loading: 'Looking up vendor details', done: 'Looked up vendor details' },
Expand Down
Loading
Loading