Skip to content

Commit d20e219

Browse files
michelle-hadfield-navayoomlamRyanHanszRyan Hanszclaude
authored
FEAT: add streaming action plan response to frontend (#140)
Co-authored-by: Yoom Lam <yoom@navapbc.com> Co-authored-by: Ryan Hansz <63253539+RyanHansz@users.noreply.github.com> Co-authored-by: Ryan Hansz <ryan@Ryans-MacBook-Pro-2.local> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 439d26e commit d20e219

6 files changed

Lines changed: 420 additions & 11 deletions

File tree

frontend/src/app/[locale]/generate-referrals/page.tsx

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import "@/app/globals.css";
1010
import { fetchLocationFromZip } from "@/util/fetchLocation";
1111

1212
import { PrintableReferralsReport } from "@/util/printReferrals";
13-
import { fetchActionPlan, ActionPlan } from "@/util/fetchActionPlan";
13+
import {
14+
fetchActionPlan, // eslint-disable-line @typescript-eslint/no-unused-vars -- will be used for user preference between streaming/non-streaming
15+
fetchActionPlanStreaming,
16+
ActionPlan,
17+
PartialActionPlan,
18+
} from "@/util/fetchActionPlan";
1419
import { ActionPlanSection } from "@/components/ActionPlanSection";
1520

1621
import ResourcesList from "@/components/ResourcesList";
@@ -46,6 +51,10 @@ export default function Page() {
4651
const [selectedResources, setSelectedResources] = useState<Resource[]>([]);
4752
const [actionPlan, setActionPlan] = useState<ActionPlan | null>(null);
4853
const [isGeneratingActionPlan, setIsGeneratingActionPlan] = useState(false);
54+
const [streamingPlan, setStreamingPlan] = useState<PartialActionPlan | null>(
55+
null,
56+
);
57+
const [isStreaming, setIsStreaming] = useState(false);
4958
const [activeTab, setActiveTab] = useState("find-referrals");
5059
const [errorMessage, setErrorMessage] = useState<string | undefined>(
5160
undefined,
@@ -176,29 +185,66 @@ export default function Page() {
176185
if (selectedResources.length === 0) return;
177186

178187
setIsGeneratingActionPlan(true);
188+
setIsStreaming(true);
179189
setActionPlan(null);
190+
setStreamingPlan(null);
180191
setErrorMessage(undefined);
181192

182193
try {
183194
const {
184-
actionPlan: plan,
195+
actionPlan: finalPlan,
185196
resultId,
186197
errorMessage: planError,
187-
} = await fetchActionPlan(
198+
} = await fetchActionPlanStreaming(
188199
selectedResources,
189200
userEmail,
190201
clientDescription,
202+
// onChunk callback - receive structured partial plan
203+
(partialPlan: PartialActionPlan) => {
204+
setStreamingPlan(partialPlan);
205+
},
206+
// onComplete callback - stop streaming UI
207+
() => {
208+
setIsStreaming(false);
209+
},
210+
// onError callback - clear content and show error
211+
(error: string) => {
212+
setIsStreaming(false);
213+
setIsGeneratingActionPlan(false);
214+
setStreamingPlan(null);
215+
setActionPlan(null);
216+
setErrorMessage(error);
217+
},
191218
);
192-
setActionPlan(plan);
193-
setActionPlanResultId(resultId);
219+
220+
// Handle errors from the streaming response
194221
if (planError) {
195222
setErrorMessage(planError);
196-
setActionPlanResultId(""); // set the Action PLan Id to "" so we don't email an empty or errant result
223+
setActionPlanResultId(""); // set the Action Plan Id to "" so we don't email an empty or errant result
224+
setStreamingPlan(null);
225+
setActionPlan(null);
226+
return;
227+
}
228+
229+
// Set the final parsed action plan
230+
if (finalPlan) {
231+
setActionPlan(finalPlan);
232+
setActionPlanResultId(resultId);
233+
setStreamingPlan(null); // Clear streaming state
234+
} else {
235+
setErrorMessage(
236+
"There was an issue streaming the Action Plan. Please try again.",
237+
);
238+
setActionPlanResultId("");
197239
}
198240
} catch (error) {
199241
console.error("Error generating action plan:", error);
242+
setIsStreaming(false);
243+
setIsGeneratingActionPlan(false);
244+
setStreamingPlan(null);
245+
setActionPlan(null);
200246
setErrorMessage(
201-
"The server encountered an unexpected error. Please try again later.",
247+
"There was an issue streaming the Action Plan. Please try again.",
202248
);
203249
} finally {
204250
setIsGeneratingActionPlan(false);
@@ -419,6 +465,7 @@ export default function Page() {
419465
onPrint={handlePrint}
420466
resourcesResultId={resourcesResultId}
421467
actionPlanResultId={actionPlanResultId}
468+
disabled={isStreaming}
422469
/>
423470
</div>
424471
</div>
@@ -446,6 +493,8 @@ export default function Page() {
446493
selectedResources={selectedResources}
447494
actionPlan={actionPlan}
448495
isGeneratingActionPlan={isGeneratingActionPlan}
496+
streamingPlan={streamingPlan}
497+
isStreaming={isStreaming}
449498
onResourceSelection={handleResourceSelection}
450499
onSelectAllResources={handleSelectAllResources}
451500
onGenerateActionPlan={() => void generateActionPlan()}
@@ -463,6 +512,7 @@ export default function Page() {
463512
actionPlanResultId={actionPlanResultId}
464513
className="justify-end"
465514
testIdSuffix="bottom"
515+
disabled={isStreaming}
466516
/>
467517
</div>
468518
)}

frontend/src/components/ActionPlanDisplay.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,62 @@
1-
import { ActionPlan } from "@/util/fetchActionPlan";
1+
import { ActionPlan, PartialActionPlan } from "@/util/fetchActionPlan";
22
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
33
import { FileText } from "lucide-react";
44
import { parseMarkdownToHTML } from "@/util/markdown";
5-
export function ActionPlanDisplay({ actionPlan }: { actionPlan: ActionPlan }) {
5+
6+
interface ActionPlanDisplayProps {
7+
actionPlan: ActionPlan | null;
8+
streamingPlan?: PartialActionPlan | null;
9+
isStreaming?: boolean;
10+
}
11+
12+
export function ActionPlanDisplay({
13+
actionPlan,
14+
streamingPlan,
15+
isStreaming,
16+
}: ActionPlanDisplayProps) {
17+
// If streaming, show partial content with progressive rendering
18+
if (isStreaming && streamingPlan) {
19+
const displayTitle = streamingPlan.title || "Generating Action Plan...";
20+
21+
return (
22+
<Card className="bg-blue-50 border-blue-200 shadow-sm mb-5">
23+
<CardHeader>
24+
<CardTitle className="text-lg font-semibold text-blue-900 flex items-center gap-2">
25+
<FileText className="w-5 h-5 text-blue-600" />
26+
{displayTitle}
27+
</CardTitle>
28+
</CardHeader>
29+
<CardContent>
30+
{/* Show summary if available */}
31+
{streamingPlan.summary && (
32+
<div className="mb-3 text-black text-base">
33+
{streamingPlan.summary}
34+
</div>
35+
)}
36+
37+
{/* Show content with markdown rendering and blinking cursor */}
38+
{streamingPlan.content && (
39+
<div className="relative">
40+
<div
41+
className="prose prose-sm max-w-none text-gray-800"
42+
dangerouslySetInnerHTML={{
43+
__html: parseMarkdownToHTML(streamingPlan.content),
44+
}}
45+
/>
46+
{/* Blinking cursor to indicate streaming */}
47+
<span className="inline-block w-2 h-4 bg-blue-600 animate-pulse ml-1 align-middle">
48+
|
49+
</span>
50+
</div>
51+
)}
52+
</CardContent>
53+
</Card>
54+
);
55+
}
56+
57+
// Otherwise show parsed action plan (final complete version)
58+
if (!actionPlan) return null;
59+
660
return (
761
<Card className="bg-blue-50 border-blue-200 shadow-sm mb-5">
862
<CardHeader>

frontend/src/components/ActionPlanSection.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import { Button } from "@/components/ui/button";
33
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
44
import { Spinner } from "@/components/ui/spinner";
55
import { Resource } from "@/types/resources";
6-
import { ActionPlan } from "@/util/fetchActionPlan";
6+
import { ActionPlan, PartialActionPlan } from "@/util/fetchActionPlan";
77
import { ActionPlanDisplay } from "@/components/ActionPlanDisplay";
88

99
interface ActionPlanSectionProps {
1010
resources: Resource[];
1111
selectedResources: Resource[];
1212
actionPlan: ActionPlan | null;
1313
isGeneratingActionPlan: boolean;
14+
streamingPlan?: PartialActionPlan | null;
15+
isStreaming?: boolean;
1416
onResourceSelection: (resource: Resource, checked: boolean) => void;
1517
onSelectAllResources: () => void;
1618
onGenerateActionPlan: () => void;
@@ -21,6 +23,8 @@ export function ActionPlanSection({
2123
selectedResources,
2224
actionPlan,
2325
isGeneratingActionPlan,
26+
streamingPlan,
27+
isStreaming,
2428
onResourceSelection,
2529
onSelectAllResources,
2630
onGenerateActionPlan,
@@ -97,7 +101,13 @@ export function ActionPlanSection({
97101
</Card>
98102

99103
{/* Action Plan Display */}
100-
{actionPlan && <ActionPlanDisplay actionPlan={actionPlan} />}
104+
{(actionPlan || isStreaming) && (
105+
<ActionPlanDisplay
106+
actionPlan={actionPlan}
107+
streamingPlan={streamingPlan}
108+
isStreaming={isStreaming}
109+
/>
110+
)}
101111
</>
102112
);
103113
}

frontend/src/components/EmailReferralsButton.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ import { emailResult } from "@/util/emailResult";
2020
interface EmailReferralsProps {
2121
resultId: string;
2222
actionPlanResultId?: string;
23+
disabled?: boolean;
2324
}
2425

2526
export function EmailReferralsButton({
2627
resultId,
2728
actionPlanResultId,
29+
disabled = false,
2830
}: EmailReferralsProps) {
2931
const [email, setEmail] = useState("");
3032
const [isOpen, setIsOpen] = useState(false);
@@ -72,6 +74,7 @@ export function EmailReferralsButton({
7274
variant="outline"
7375
className="hover:bg-gray-100 hover:text-gray-900"
7476
data-testid="emailReferralsButton"
77+
disabled={disabled}
7578
>
7679
<Mail className="w-4 h-4" />
7780
Email

frontend/src/components/ShareButtons.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface ShareButtonsProps {
99
actionPlanResultId: string;
1010
className?: string;
1111
testIdSuffix?: string;
12+
disabled?: boolean;
1213
}
1314

1415
export function ShareButtons({
@@ -17,6 +18,7 @@ export function ShareButtons({
1718
actionPlanResultId,
1819
className,
1920
testIdSuffix,
21+
disabled = false,
2022
}: ShareButtonsProps) {
2123
return (
2224
<div className={cn("flex gap-2", className)}>
@@ -25,6 +27,7 @@ export function ShareButtons({
2527
variant="outline"
2628
className="hover:bg-gray-100 hover:text-gray-900"
2729
data-testid={`printReferralsButton${testIdSuffix ? `-${testIdSuffix}` : ""}`}
30+
disabled={disabled}
2831
>
2932
<Printer className="w-4 h-4" aria-hidden="true" />
3033
Print
@@ -33,6 +36,7 @@ export function ShareButtons({
3336
<EmailReferralsButton
3437
resultId={resourcesResultId}
3538
actionPlanResultId={actionPlanResultId || undefined}
39+
disabled={disabled}
3640
/>
3741
)}
3842
</div>

0 commit comments

Comments
 (0)