Skip to content

Commit 4ee6538

Browse files
committed
style: travel planner
1 parent e328659 commit 4ee6538

4 files changed

Lines changed: 218 additions & 69 deletions

File tree

ai-server/src/mastra/workflows/package-tour-workflow.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ const finalizeStep = createStep({
208208
export const packageTourWorkflow = createWorkflow({
209209
id: 'packageTourWorkflow',
210210
description:
211-
'Proposes a multi-stop package tour: searches flights for all legs, finds hotels for all destination cities, then finalizes the result.',
211+
'Proposes a multi-stop package tour: searches flights for all legs, then finds hotels for all destination cities, then finalizes the result.',
212212
inputSchema: packageInputSchema,
213213
outputSchema: packageOutputSchema,
214214
})

src/app/domains/ticketing/ai/travel-planner/travel-planner-page.css

Lines changed: 125 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -68,59 +68,144 @@
6868
cursor: pointer;
6969
}
7070

71-
.status-bar {
71+
.step-tracker-row {
7272
display: flex;
73-
flex-wrap: wrap;
73+
align-items: flex-start;
74+
gap: 1rem;
75+
margin: 0.75rem 0 1.25rem;
76+
}
77+
78+
.step-tracker {
79+
display: flex;
80+
align-items: flex-start;
81+
flex: 1 1 auto;
82+
min-width: 0;
83+
}
84+
85+
.step-node {
86+
display: flex;
87+
flex-direction: column;
7488
align-items: center;
75-
gap: 0.75rem;
76-
margin: 0.5rem 0 1rem;
89+
gap: 0.35rem;
90+
flex-shrink: 0;
91+
min-width: 4rem;
7792
}
7893

79-
.status-pill {
80-
display: inline-flex;
94+
.step-icon {
95+
width: 2rem;
96+
height: 2rem;
97+
border-radius: 50%;
98+
border: 2px solid;
99+
display: flex;
81100
align-items: center;
82-
gap: 0.6rem;
83-
padding: 0.4rem 0.8rem;
84-
background: rgba(15, 23, 42, 0.05);
85-
border: 1px solid rgba(15, 23, 42, 0.1);
86-
border-radius: 999px;
87-
font-size: 0.85rem;
88-
color: rgba(15, 23, 42, 0.85);
101+
justify-content: center;
102+
font-size: 0.8rem;
103+
font-weight: 700;
104+
transition:
105+
border-color 0.2s,
106+
background 0.2s,
107+
color 0.2s;
89108
}
90109

91-
.status-text {
92-
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
110+
.step-node[data-state='upcoming'] .step-icon {
111+
border-color: rgba(15, 23, 42, 0.2);
112+
color: rgba(15, 23, 42, 0.3);
113+
background: transparent;
93114
}
94115

95-
.status-pill .status-dots {
96-
align-items: center;
97-
gap: 0.25rem;
98-
margin: 0;
116+
.step-node[data-state='active'] .step-icon {
117+
border-color: #2563eb;
118+
color: #2563eb;
119+
background: rgba(37, 99, 235, 0.07);
99120
}
100121

101-
.status-pill .status-dots span {
102-
width: 0.4rem;
103-
height: 0.4rem;
122+
.step-node[data-state='done'] .step-icon {
123+
border-color: #16a34a;
124+
color: #16a34a;
125+
background: rgba(22, 163, 74, 0.07);
104126
}
105127

106-
.workflow-step {
107-
display: inline-flex;
108-
align-items: baseline;
109-
gap: 0.4rem;
110-
padding: 0.4rem 0.8rem;
111-
background: rgba(59, 130, 246, 0.08);
112-
border: 1px solid rgba(59, 130, 246, 0.25);
113-
border-radius: 999px;
114-
font-size: 0.85rem;
128+
.step-label {
129+
font-size: 0.7rem;
130+
font-weight: 500;
131+
white-space: nowrap;
132+
transition: color 0.2s;
115133
}
116134

117-
.workflow-step-label {
118-
color: rgba(15, 23, 42, 0.6);
135+
.step-node[data-state='upcoming'] .step-label {
136+
color: rgba(15, 23, 42, 0.3);
119137
}
120138

121-
.workflow-step-value {
122-
font-weight: var(--font-weight-bold);
123-
color: #1d4ed8;
139+
.step-node[data-state='active'] .step-label {
140+
color: #2563eb;
141+
font-weight: 700;
142+
}
143+
144+
.step-node[data-state='done'] .step-label {
145+
color: #16a34a;
146+
}
147+
148+
.step-connector {
149+
flex: 1 1 0%;
150+
height: 2px;
151+
min-width: 2.5rem;
152+
margin-top: 0.9rem;
153+
background: rgba(15, 23, 42, 0.12);
154+
transition: background 0.3s;
155+
}
156+
157+
.step-connector.done {
158+
background: #16a34a;
159+
}
160+
161+
.step-spinner {
162+
display: inline-block;
163+
width: 0.85rem;
164+
height: 0.85rem;
165+
border: 2px solid rgba(37, 99, 235, 0.2);
166+
border-top-color: #2563eb;
167+
border-radius: 50%;
168+
animation: step-spin 0.7s linear infinite;
169+
}
170+
171+
@keyframes step-spin {
172+
to {
173+
transform: rotate(360deg);
174+
}
175+
}
176+
177+
.step-more-btn {
178+
align-self: flex-start;
179+
background: transparent;
180+
border: 1px solid rgba(15, 23, 42, 0.15);
181+
border-radius: 6px;
182+
box-sizing: border-box;
183+
color: rgba(15, 23, 42, 0.55);
184+
cursor: pointer;
185+
flex-shrink: 0;
186+
font-size: 0.75rem;
187+
height: 1.75rem;
188+
line-height: 1.75rem;
189+
margin-top: calc(0.5rem - 5px);
190+
min-width: 3.5rem;
191+
padding: 0 0.75rem;
192+
text-align: center;
193+
white-space: nowrap;
194+
}
195+
196+
.step-more-btn:disabled {
197+
cursor: default;
198+
opacity: 0.4;
199+
}
200+
201+
.step-more-btn:not(:disabled):hover {
202+
border-color: rgba(15, 23, 42, 0.3);
203+
color: rgba(15, 23, 42, 0.75);
204+
}
205+
206+
.step-more-btn:focus-visible {
207+
outline: 2px solid var(--color-primary, #1d4ed8);
208+
outline-offset: 2px;
124209
}
125210

126211
.status-toggle {
@@ -335,7 +420,9 @@
335420
}
336421

337422
@media (min-width: 1200px) {
338-
.planner-output {
423+
.step-tracker-row,
424+
.planner-output,
425+
.planner-error {
339426
max-width: 50%;
340427
}
341428
}
@@ -368,7 +455,7 @@
368455

369456
.flight-cell {
370457
flex: 1 1 0;
371-
min-height: 160px;
458+
min-height: 205px;
372459
display: flex;
373460
flex-direction: column;
374461
}

src/app/domains/ticketing/ai/travel-planner/travel-planner-page.html

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -54,31 +54,41 @@ <h1>Travel Planner</h1>
5454
</div>
5555
</form>
5656

57-
@if (chat.isLoading() || workflowSteps().length > 0 || toolCalls().length > 0) {
58-
<div class="status-bar">
59-
<div class="status-pill">
60-
<span class="status-text">{{ currentStatus() }}</span>
61-
@if (chat.isLoading()) {
62-
<div class="ai-wait-indicator status-dots" aria-hidden="true">
63-
<span></span>
64-
<span></span>
65-
<span></span>
57+
@if (chat.isLoading() || workflowSteps().length > 0) {
58+
<div class="step-tracker-row">
59+
<div class="step-tracker">
60+
@for (
61+
step of stepPipeline();
62+
track step.id;
63+
let i = $index;
64+
let last = $last
65+
) {
66+
<div class="step-node" [attr.data-state]="step.state">
67+
<div class="step-icon">
68+
@if (step.state === 'done') {
69+
70+
} @else if (step.state === 'active') {
71+
<span class="step-spinner"></span>
72+
} @else {
73+
{{ i + 1 }}
74+
}
75+
</div>
76+
<span class="step-label">{{ step.label }}</span>
6677
</div>
78+
@if (!last) {
79+
<div
80+
class="step-connector"
81+
[class.done]="step.state === 'done'"></div>
82+
}
6783
}
6884
</div>
69-
70-
@if (currentWorkflowStep(); as step) {
71-
<div class="workflow-step">
72-
<span class="workflow-step-label">Current workflow step:</span>
73-
<span class="workflow-step-value">{{ step }}</span>
74-
</div>
75-
}
76-
77-
@if (workflowSteps().length > 0 || toolCalls().length > 0) {
78-
<button type="button" class="status-toggle" (click)="toggleToolDetails()">
79-
{{ showToolDetails() ? 'Less' : 'More details' }}
80-
</button>
81-
}
85+
<button
86+
type="button"
87+
class="step-more-btn"
88+
[disabled]="workflowSteps().length === 0 && toolCalls().length === 0"
89+
(click)="toggleToolDetails()">
90+
{{ showToolDetails() ? 'Less' : 'More' }}
91+
</button>
8292
</div>
8393
}
8494

src/app/domains/ticketing/ai/travel-planner/travel-planner-page.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,25 @@ const DURATION_OPTIONS = [
3030
] as const;
3131

3232
const WORKFLOW_STEP_LABELS: Record<string, string> = {
33-
findOutboundFlights: 'Searching outbound flights',
34-
findReturnFlights: 'Searching return flights',
35-
findHotels: 'Searching hotels',
36-
evaluateHotels: 'Evaluating hotels',
37-
hotelMatchState: 'Matching hotel found',
38-
hotelFallbackState: 'No matching hotel — travel agency takes over',
39-
finalize: 'Finalizing result',
40-
showComponents: 'Rendering UI',
33+
findFlights: 'Flights',
34+
findHotels: 'Hotels',
35+
finalize: 'Travel Plan',
4136
};
4237

38+
const PIPELINE_STEPS = [
39+
{ id: 'findFlights', label: 'Flights' },
40+
{ id: 'findHotels', label: 'Hotels' },
41+
{ id: '_plan', label: 'Travel Plan' },
42+
] as const;
43+
44+
type PipelineStepState = 'upcoming' | 'active' | 'done';
45+
46+
interface PipelineStep {
47+
id: string;
48+
label: string;
49+
state: PipelineStepState;
50+
}
51+
4352
@Component({
4453
selector: 'app-travel-planner-page',
4554
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -127,6 +136,14 @@ export class TravelPlannerPage {
127136
);
128137
});
129138

139+
protected readonly stepPipeline = computed<PipelineStep[]>(() =>
140+
buildPipeline(
141+
this.workflowSteps(),
142+
this.chat.isLoading(),
143+
this.widgets().length > 0,
144+
),
145+
);
146+
130147
protected readonly showToolDetails = signal(false);
131148

132149
protected submit(): void {
@@ -293,3 +310,38 @@ function formatToolArgsValue(args: unknown): string {
293310
function readStepLabel(name: string, labels: Record<string, string>): string {
294311
return labels[name] ?? name;
295312
}
313+
314+
function buildPipeline(
315+
steps: AgUiWorkflowStep[],
316+
isLoading: boolean,
317+
hasWidgets: boolean,
318+
): PipelineStep[] {
319+
const statusMap = new Map<string, string>();
320+
for (const step of steps) {
321+
statusMap.set(step.name, step.status);
322+
}
323+
324+
const sequentialIds = ['findFlights', 'findHotels'];
325+
const allSequentialDone = sequentialIds.every(
326+
(id) => statusMap.get(id) === 'complete',
327+
);
328+
const firstIncomplete = sequentialIds.find(
329+
(id) => statusMap.get(id) !== 'complete',
330+
);
331+
const finalizeStarted =
332+
statusMap.has('finalize') || (allSequentialDone && isLoading);
333+
334+
return PIPELINE_STEPS.map(({ id, label }) => {
335+
if (id === '_plan') {
336+
if (!isLoading && hasWidgets) return { id, label, state: 'done' };
337+
if (finalizeStarted) return { id, label, state: 'active' };
338+
return { id, label, state: 'upcoming' };
339+
}
340+
const status = statusMap.get(id);
341+
if (status === 'complete') return { id, label, state: 'done' };
342+
if (status === 'pending') return { id, label, state: 'active' };
343+
if (isLoading && id === firstIncomplete)
344+
return { id, label, state: 'active' };
345+
return { id, label, state: 'upcoming' };
346+
});
347+
}

0 commit comments

Comments
 (0)