@schandlergarcia/sf-web-components 2.3.16 → 2.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.3.17] - 2026-04-15
9
+
10
+ ### Changed
11
+ - **Two-agent chat system** — AgentPanel now supports Partner Service Agent (booking recovery, default) and Engine Virtual Agent (case triage with handoff). Agents swap via `npm run agent:partner` / `agent:engine` which edits the source directly for instant HMR.
12
+ - **Structured card renderer for chat** — Messages with headers (OPEN CASES SUMMARY, CANCELED BOOKING, PROMOTION LIVE) render as formatted cards with priority dots, key-value pairs, and color-coded sections instead of plain text.
13
+ - **Improved chat readability** — Replaced CSS variable colors with high-contrast solid colors. Warm parchment backgrounds, dark text, visible borders. Bumped all font sizes +1px.
14
+ - **Cascading skeleton loading** — Tab switch skeletons now fade in with randomized stagger delays per element instead of uniform pulse. Each element has a unique random delay for natural feel.
15
+ - **Streamlined promo flow** — Removed ENGINE NETWORK PROMOTION card; upsell pitch now follows directly after CANCELED BOOKING card in a single conversational message.
16
+ - **Demo state manager** — `demo.sh` now tracks AgentPanel.tsx. Added `demo:d360` npm shortcut to fast-forward to Data 360 tab state.
17
+ - **Data 360 demo state** — Pre-built demo version with Data 360 booking segmentation tab, saveable/restorable via `demo:d360` / `demo:reset`.
18
+
8
19
  ## [2.3.16] - 2026-04-14
9
20
 
10
21
  ### Changed
@@ -4,6 +4,7 @@ import { SparklesIcon } from "@heroicons/react/24/solid";
4
4
  import {
5
5
  PaperAirplaneIcon,
6
6
  ChevronDownIcon,
7
+ ArrowPathIcon,
7
8
  } from "@heroicons/react/24/outline";
8
9
 
9
10
  interface Message {
@@ -11,6 +12,7 @@ interface Message {
11
12
  role: "user" | "agent";
12
13
  text: string;
13
14
  timestamp: string;
15
+ agentName?: string;
14
16
  }
15
17
 
16
18
  interface ScriptStep {
@@ -18,63 +20,102 @@ interface ScriptStep {
18
20
  text: string;
19
21
  }
20
22
 
21
- const SCRIPT: ScriptStep[] = [
22
- // --- Auto-play greeting ---
23
- {
24
- role: "agent",
25
- text: "Hi Jamie! 👋 I'm your Partner Hub assistant. How can I help you today?",
26
- },
27
-
28
- // --- Prompt 1: Ask about the canceled booking ---
29
- {
30
- role: "user",
31
- text: "I just got an alert that a block booking was canceled at our Austin Convention Center for next week. What happened?",
32
- },
33
- {
34
- role: "agent",
35
- text: "Let me pull up that reservation for you...",
36
- },
37
- {
38
- role: "agent",
39
- text: "🔴 CANCELED BOOKING\n\n🏨 Summit Austin Convention Center\n🏢 Client: TechCorp Inc.\n\n📋 Details:\n• Original block: 40 rooms\n• Dates: May 19 – 21 (3 nights)\n• Room rate: $200/night\n• Lost revenue: $24,000\n\n📅 Canceled: Apr 11, 2026\n⚠️ Status: Within penalty window",
40
- },
41
- {
42
- role: "agent",
43
- text: "TechCorp canceled their 40-room block for May 19–21. The reason given was that their annual conference is moving to a virtual format this year. This was within the attrition penalty window, so a penalty may apply — but that still leaves 40 rooms open for next week.",
44
- },
23
+ type AgentId = "service" | "vee";
45
24
 
46
- // --- Prompt 2: What can be done ---
47
- {
48
- role: "user",
49
- text: "That's a lot of empty rooms. Is there anything we can do to fill them on short notice?",
50
- },
51
- {
52
- role: "agent",
53
- text: "Actually, yes — I have an idea. Let me put together an option for you.",
54
- },
55
- {
56
- role: "agent",
57
- text: "⚡ ENGINE NETWORK PROMOTION\nFill your open rooms through Engine's network\n\n━━━━━━━━━━━━━━━━━━━━━━━━━\nSummit Austin Convention Center\n40 rooms · May 19–21 · 3 nights\n━━━━━━━━━━━━━━━━━━━━━━━━━\n\n💰 YOUR RATE\n$159/night (20% off rack rate)\n\n📡 ENGINE PROMOTES TO\n2,400+ corporate travel managers\n\n📉 COMMISSION\n12% standard → 8% promotional\nYou save $1,526 vs. standard bookings\n\n━━━━━━━━━━━━━━━━━━━━━━━━━\n📈 PROJECTED OUTCOME\n• Est. fill: 28 rooms (70%)\n• Revenue: $13,356\n• Net after commission: $12,287\n━━━━━━━━━━━━━━━━━━━━━━━━━",
58
- },
59
- {
60
- role: "agent",
61
- text: "Here's the play: we run a targeted promotion across Engine's corporate travel network — 2,400+ travel managers get notified about discounted rates at Summit Austin for May 19–21.\n\nWe'd list the rooms at $159/night, and your commission drops from 12% to 8% for promo bookings. On a projected 70% fill, that's $12,287 net — versus $0 if the rooms sit empty.\n\nWant to launch it?",
62
- },
25
+ interface AgentConfig {
26
+ name: string;
27
+ subtitle: string;
28
+ script: ScriptStep[];
29
+ standaloneGreeting?: string;
30
+ }
63
31
 
64
- // --- Prompt 3: Accept the offer ---
65
- {
66
- role: "user",
67
- text: "That makes sense — empty rooms earn nothing. Let's launch it.",
68
- },
69
- {
70
- role: "agent",
71
- text: "Launching your promotion now...",
32
+ const AGENTS: Record<AgentId, AgentConfig> = {
33
+ service: {
34
+ name: "Partner Service Agent",
35
+ subtitle: "Agentforce - Online",
36
+ script: [
37
+ {
38
+ role: "agent",
39
+ text: "Hi Jamie! I'm your Partner Service Agent. I can help you triage cases, check property performance, or review billing items. What would you like to look at?",
40
+ },
41
+ {
42
+ role: "user",
43
+ text: "I have a few open cases. Can you give me a quick summary of what needs attention?",
44
+ },
45
+ {
46
+ role: "agent",
47
+ text: "Sure. Let me pull your open cases across all properties...",
48
+ },
49
+ {
50
+ role: "agent",
51
+ text: "OPEN CASES SUMMARY\n\n7 active cases across 4 properties\n\nURGENT (2)\n- CS-1039: Room cleanliness, 3rd occurrence (Chicago Downtown, Escalated)\n- CS-1034: Penalty calculation review (Austin Conv. Ctr, Escalated)\n\nNEEDS REVIEW (2)\n- CS-1040: Missing resale credit ATR-00001 (Austin, At Risk SLA)\n- CS-1037: Commission rate discrepancy (Midtown NYC)\n\nIN PROGRESS (2)\n- CS-1041: HVAC failure, conference room B (SF Bay)\n- CS-1035: Pool heater malfunction (SF Bay)\n\nLOW PRIORITY (1)\n- CS-1038: VIP late checkout request (SF Bay)",
52
+ },
53
+ {
54
+ role: "agent",
55
+ text: "Your two escalated cases need the most attention. CS-1039 is a repeat cleanliness complaint at Chicago Downtown, now on its 3rd occurrence. CS-1034 is the penalty calculation dispute at Austin. Would you like me to dig into either of those, or is there something else I can help with?",
56
+ },
57
+ {
58
+ role: "user",
59
+ text: "Actually, I just got a cancellation alert for Austin. Can someone help me figure out what to do about 40 empty rooms?",
60
+ },
61
+ {
62
+ role: "agent",
63
+ text: "That sounds like it needs our revenue recovery specialist. Let me connect you with Vee, the Engine Virtual Agent. She handles booking cancellations and can set up promotions to fill those rooms. One moment...",
64
+ },
65
+ {
66
+ role: "agent",
67
+ text: "HANDOFF",
68
+ },
69
+ ],
72
70
  },
73
- {
74
- role: "agent",
75
- text: " PROMOTION LIVE\n\n🆔 PROMO-SUM-0519\n🏨 Summit Austin Convention Center\n📅 May 19–21 (3 nights)\n💰 Rate: $159/night\n📉 Commission: 8% (promotional)\n📡 Distribution: Engine corporate travel network\n\nYour promotion is live and going out to 2,400+ travel managers now. I'll send you a fill-rate update in 48 hours.",
71
+ vee: {
72
+ name: "Vee, Engine Virtual Agent",
73
+ subtitle: "Agentforce - Online",
74
+ standaloneGreeting: "Hi Jamie! I'm Vee, your Engine Virtual Agent. I handle bookings, cancellations, and revenue opportunities across your properties. How can I help?",
75
+ script: [
76
+ {
77
+ role: "agent",
78
+ text: "Hi Jamie, Vee here. I've been briefed on the situation: a 40-room cancellation at Austin Convention Center. Let me pull up the details.",
79
+ },
80
+ {
81
+ role: "agent",
82
+ text: "CANCELED BOOKING\n\nSummit Austin Convention Center\nClient: TechCorp Inc.\n\nDetails:\n- Original block: 40 rooms\n- Dates: May 19-21 (3 nights)\n- Room rate: $200/night\n- Lost revenue: $24,000\n\nCanceled: Apr 14, 2026\nStatus: Within penalty window",
83
+ },
84
+ {
85
+ role: "agent",
86
+ text: "TechCorp canceled their 40-room block for May 19-21. The reason given was that their annual conference is moving to a virtual format this year. This was within the attrition penalty window, so a penalty may apply, but that still leaves 40 rooms open for next week.",
87
+ },
88
+ {
89
+ role: "user",
90
+ text: "That's a lot of empty rooms. Is there anything we can do to fill them on short notice?",
91
+ },
92
+ {
93
+ role: "agent",
94
+ text: "Actually, yes. I have an idea. Let me put together an option for you.",
95
+ },
96
+ {
97
+ role: "agent",
98
+ text: "ENGINE NETWORK PROMOTION\nFill your open rooms through Engine's network\n\n---\nSummit Austin Convention Center\n40 rooms, May 19-21, 3 nights\n---\n\nYOUR RATE\n$159/night (20% off rack rate)\n\nENGINE PROMOTES TO\n2,400+ corporate travel managers\n\nCOMMISSION\n12% standard > 8% promotional\nYou save $1,526 vs. standard bookings\n\n---\nPROJECTED OUTCOME\n- Est. fill: 28 rooms (70%)\n- Revenue: $13,356\n- Net after commission: $12,287\n---",
99
+ },
100
+ {
101
+ role: "agent",
102
+ text: "Here's the play: we run a targeted promotion across Engine's corporate travel network. 2,400+ travel managers get notified about discounted rates at Summit Austin for May 19-21.\n\nWe'd list the rooms at $159/night, and your commission drops from 12% to 8% for promo bookings. On a projected 70% fill, that's $12,287 net versus $0 if the rooms sit empty.\n\nWant to launch it?",
103
+ },
104
+ {
105
+ role: "user",
106
+ text: "That makes sense. Empty rooms earn nothing. Let's launch it.",
107
+ },
108
+ {
109
+ role: "agent",
110
+ text: "Launching your promotion now...",
111
+ },
112
+ {
113
+ role: "agent",
114
+ text: "PROMOTION LIVE\n\nPROMO-SUM-0519\nSummit Austin Convention Center\nMay 19-21 (3 nights)\nRate: $159/night\nCommission: 8% (promotional)\nDistribution: Engine corporate travel network\n\nYour promotion is live and going out to 2,400+ travel managers now. I'll send you a fill-rate update in 48 hours.",
115
+ },
116
+ ],
76
117
  },
77
- ];
118
+ };
78
119
 
79
120
  function typingDelay(text: string): number {
80
121
  const base = 900;
@@ -106,16 +147,29 @@ function TypingIndicator() {
106
147
  );
107
148
  }
108
149
 
150
+ function HandoffBanner({ fromAgent, toAgent }: { fromAgent: string; toAgent: string }) {
151
+ return (
152
+ <div className="px-4 py-3">
153
+ <div className="flex items-center gap-3 bg-[var(--color-dash-accent)]/5 border border-[var(--color-dash-accent)]/20 rounded-xl px-4 py-3">
154
+ <ArrowPathIcon className="h-4 w-4 text-[var(--color-dash-accent)] flex-shrink-0" />
155
+ <p className="text-xs text-[var(--color-dash-text)] dark:text-white">
156
+ <span className="font-semibold">{fromAgent}</span> transferred you to <span className="font-semibold">{toAgent}</span>
157
+ </p>
158
+ </div>
159
+ </div>
160
+ );
161
+ }
162
+
109
163
  function AgentMessage({ text, timestamp }: { text: string; timestamp: string }) {
110
164
  return (
111
165
  <div className="px-4 py-1.5">
112
- <div className="flex items-end gap-3">
113
- <div className="flex-shrink-0 h-7 w-7 rounded-full bg-[var(--color-dash-dark)] flex items-center justify-center">
166
+ <div className="flex items-start gap-3">
167
+ <div className="flex-shrink-0 h-7 w-7 rounded-full bg-[var(--color-dash-dark)] flex items-center justify-center mt-0.5">
114
168
  <SparklesIcon className="h-3.5 w-3.5 text-white" />
115
169
  </div>
116
- <div className="max-w-[80%]">
170
+ <div className="max-w-[85%]">
117
171
  <div className="bg-[var(--color-dash-surface)] dark:bg-[var(--color-dash-muted)]/20 rounded-2xl rounded-bl-md px-4 py-3 border border-[var(--color-dash-border)]/30 dark:border-[var(--color-dash-muted)]/30">
118
- <p className="text-sm text-[var(--color-dash-text)] dark:text-white leading-relaxed whitespace-pre-wrap">
172
+ <p className="text-[13px] text-[var(--color-dash-text)] dark:text-white leading-relaxed whitespace-pre-wrap">
119
173
  {text}
120
174
  </p>
121
175
  </div>
@@ -131,7 +185,7 @@ function UserMessage({ text, timestamp }: { text: string; timestamp: string }) {
131
185
  <div className="px-4 py-1.5">
132
186
  <div className="flex items-end justify-end gap-3">
133
187
  <div className="max-w-[80%]">
134
- <div className="bg-[var(--color-dash-accent)] rounded-2xl rounded-br-md px-4 py-3">
188
+ <div className="bg-[var(--color-dash-dark)] rounded-2xl rounded-br-md px-4 py-3">
135
189
  <p className="text-sm text-white leading-relaxed">{text}</p>
136
190
  </div>
137
191
  <p className="text-[10px] text-[var(--color-dash-label)] mt-1 text-right mr-1">{timestamp}</p>
@@ -146,12 +200,35 @@ export default function AgentPanel() {
146
200
  const [messages, setMessages] = useState<Message[]>([]);
147
201
  const [isTyping, setIsTyping] = useState(false);
148
202
  const [inputValue, setInputValue] = useState("");
203
+ const [startAgent] = useState<AgentId>(() => {
204
+ const params = new URLSearchParams(window.location.search);
205
+ const fromUrl = params.get("agent") as AgentId | null;
206
+ if (fromUrl && fromUrl in AGENTS) return fromUrl;
207
+ return "service";
208
+ });
209
+ const [activeAgent, setActiveAgent] = useState<AgentId>(startAgent);
210
+ const wasHandoff = useRef(false);
149
211
  const [scriptIndex, setScriptIndex] = useState(0);
150
212
  const [isAnimating, setIsAnimating] = useState(false);
213
+ const [handoffs, setHandoffs] = useState<Array<{ afterMessageId: string; from: string; to: string }>>([]);
151
214
  const hasStartedRef = useRef(false);
152
215
  const messagesEndRef = useRef<HTMLDivElement>(null);
153
216
  const inputRef = useRef<HTMLInputElement>(null);
154
217
 
218
+ const agent = AGENTS[activeAgent];
219
+
220
+ const script = React.useMemo(() => {
221
+ const base = agent.script;
222
+ if (!wasHandoff.current && agent.standaloneGreeting) {
223
+ return [
224
+ { role: "agent" as const, text: agent.standaloneGreeting },
225
+ { role: "user" as const, text: "I just got an alert that a block booking was canceled at our Austin Convention Center for next week. What happened?" },
226
+ ...base.slice(1),
227
+ ];
228
+ }
229
+ return base;
230
+ }, [activeAgent, agent]);
231
+
155
232
  useEffect(() => {
156
233
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
157
234
  }, [messages, isTyping]);
@@ -162,7 +239,6 @@ export default function AgentPanel() {
162
239
  }
163
240
  }, [isOpen]);
164
241
 
165
- // Auto-play the first agent message when panel opens
166
242
  useEffect(() => {
167
243
  if (isOpen && !hasStartedRef.current && scriptIndex === 0) {
168
244
  hasStartedRef.current = true;
@@ -170,12 +246,67 @@ export default function AgentPanel() {
170
246
  }
171
247
  }, [isOpen]);
172
248
 
249
+ const doHandoff = useCallback(async (lastMsgId: string) => {
250
+ const fromName = AGENTS[activeAgent].name;
251
+ const toName = AGENTS.vee.name;
252
+
253
+ setHandoffs(prev => [...prev, { afterMessageId: lastMsgId, from: fromName, to: toName }]);
254
+
255
+ await delay(800);
256
+ wasHandoff.current = true;
257
+ setActiveAgent("vee");
258
+ setScriptIndex(0);
259
+
260
+ await delay(600);
261
+ await playAgentChainForScript(AGENTS.vee.script, 0);
262
+ }, [activeAgent]);
263
+
264
+ async function playAgentChainForScript(scr: ScriptStep[], startIndex: number) {
265
+ let idx = startIndex;
266
+ setIsAnimating(true);
267
+ while (idx < scr.length && scr[idx].role === "agent") {
268
+ const step = scr[idx];
269
+
270
+ if (step.text === "HANDOFF") {
271
+ idx++;
272
+ setScriptIndex(idx);
273
+ continue;
274
+ }
275
+
276
+ setIsTyping(true);
277
+ await delay(typingDelay(step.text));
278
+ setIsTyping(false);
279
+
280
+ const msg: Message = {
281
+ id: `msg-${Date.now()}-${idx}`,
282
+ role: "agent",
283
+ text: step.text,
284
+ timestamp: now(),
285
+ };
286
+ setMessages((prev) => [...prev, msg]);
287
+ setScriptIndex(idx + 1);
288
+ idx++;
289
+ if (idx < scr.length && scr[idx]?.role === "agent") {
290
+ await delay(500);
291
+ }
292
+ }
293
+ setIsAnimating(false);
294
+ }
295
+
173
296
  const playNextStep = useCallback(async () => {
174
- if (scriptIndex >= SCRIPT.length || isAnimating) return;
297
+ if (scriptIndex >= script.length || isAnimating) return;
175
298
 
176
- const step = SCRIPT[scriptIndex];
299
+ const step = script[scriptIndex];
177
300
  setIsAnimating(true);
178
301
 
302
+ if (step.text === "HANDOFF") {
303
+ const lastMsg = messages[messages.length - 1];
304
+ setScriptIndex(scriptIndex + 1);
305
+ setIsAnimating(false);
306
+ await doHandoff(lastMsg?.id || "");
307
+ return;
308
+ }
309
+
179
310
  if (step.role === "agent") {
180
311
  setIsTyping(true);
181
312
  await delay(typingDelay(step.text));
@@ -192,45 +323,50 @@ export default function AgentPanel() {
192
323
  setScriptIndex((prev) => prev + 1);
193
324
  setIsAnimating(false);
194
325
 
195
- // If we just played a user message, auto-play all following agent messages
196
326
  const nextIndex = scriptIndex + 1;
197
- if (step.role === "user" && nextIndex < SCRIPT.length && SCRIPT[nextIndex].role === "agent") {
327
+ if (step.role === "user" && nextIndex < script.length && script[nextIndex].role === "agent") {
198
328
  await delay(400);
199
- await playAgentChain(nextIndex);
200
- }
201
- }, [scriptIndex, isAnimating]);
202
329
 
203
- async function playAgentChain(startIndex: number) {
204
- let idx = startIndex;
205
- setIsAnimating(true);
206
- while (idx < SCRIPT.length && SCRIPT[idx].role === "agent") {
207
- const step = SCRIPT[idx];
208
- setIsTyping(true);
209
- await delay(typingDelay(step.text));
210
- setIsTyping(false);
211
-
212
- const msg: Message = {
213
- id: `msg-${Date.now()}-${idx}`,
214
- role: "agent",
215
- text: step.text,
216
- timestamp: now(),
217
- };
218
- setMessages((prev) => [...prev, msg]);
219
- setScriptIndex(idx + 1);
220
- idx++;
221
- if (idx < SCRIPT.length && SCRIPT[idx].role === "agent") {
222
- await delay(500);
330
+ if (script[nextIndex].text === "HANDOFF") {
331
+ // Play until handoff, then the chain message before it triggers handoff
332
+ let idx = nextIndex;
333
+ setIsAnimating(true);
334
+ while (idx < script.length && script[idx].role === "agent") {
335
+ const s = script[idx];
336
+ if (s.text === "HANDOFF") {
337
+ setScriptIndex(idx + 1);
338
+ setIsAnimating(false);
339
+ await doHandoff(messages[messages.length - 1]?.id || msg.id);
340
+ return;
341
+ }
342
+ setIsTyping(true);
343
+ await delay(typingDelay(s.text));
344
+ setIsTyping(false);
345
+ const m: Message = {
346
+ id: `msg-${Date.now()}-${idx}`,
347
+ role: "agent",
348
+ text: s.text,
349
+ timestamp: now(),
350
+ };
351
+ setMessages((prev) => [...prev, m]);
352
+ setScriptIndex(idx + 1);
353
+ idx++;
354
+ if (idx < script.length && script[idx]?.role === "agent") {
355
+ await delay(500);
356
+ }
357
+ }
358
+ setIsAnimating(false);
359
+ } else {
360
+ await playAgentChainForScript(script, nextIndex);
223
361
  }
224
362
  }
225
- setIsAnimating(false);
226
- }
363
+ }, [scriptIndex, isAnimating, script, messages, doHandoff]);
227
364
 
228
- // Right arrow key stages the next user message into the input
229
365
  useEffect(() => {
230
366
  function onKey(e: KeyboardEvent) {
231
- if (e.key === "ArrowRight" && isOpen && !isAnimating && scriptIndex < SCRIPT.length) {
367
+ if (e.key === "ArrowRight" && isOpen && !isAnimating && scriptIndex < script.length) {
232
368
  e.preventDefault();
233
- const next = SCRIPT[scriptIndex];
369
+ const next = script[scriptIndex];
234
370
  if (next.role === "user") {
235
371
  setInputValue(next.text);
236
372
  inputRef.current?.focus();
@@ -239,15 +375,14 @@ export default function AgentPanel() {
239
375
  }
240
376
  document.addEventListener("keydown", onKey);
241
377
  return () => document.removeEventListener("keydown", onKey);
242
- }, [isOpen, scriptIndex, isAnimating]);
378
+ }, [isOpen, scriptIndex, isAnimating, script]);
243
379
 
244
380
  function handleSend() {
245
381
  const text = inputValue.trim();
246
382
  if (!text) return;
247
383
  setInputValue("");
248
384
 
249
- // Check if this matches the next scripted user message
250
- if (scriptIndex < SCRIPT.length && SCRIPT[scriptIndex].role === "user" && text === SCRIPT[scriptIndex].text) {
385
+ if (scriptIndex < script.length && script[scriptIndex].role === "user" && text === script[scriptIndex].text) {
251
386
  playNextStep();
252
387
  return;
253
388
  }
@@ -266,15 +401,13 @@ export default function AgentPanel() {
266
401
  const agentMsg: Message = {
267
402
  id: `a-${Date.now()}`,
268
403
  role: "agent",
269
- text: "I'm looking into that for you one moment please.",
404
+ text: "I'm looking into that for you. One moment please.",
270
405
  timestamp: now(),
271
406
  };
272
407
  setMessages((prev) => [...prev, agentMsg]);
273
408
  }, 1500);
274
409
  }
275
410
 
276
- const scriptDone = scriptIndex >= SCRIPT.length;
277
-
278
411
  const panel = isOpen
279
412
  ? createPortal(
280
413
  <>
@@ -283,7 +416,7 @@ export default function AgentPanel() {
283
416
  onClick={() => setIsOpen(false)}
284
417
  />
285
418
  <div
286
- className="fixed z-[9999] bottom-6 right-6 w-[380px] h-[520px] flex flex-col rounded-2xl shadow-2xl border border-[var(--color-dash-label)]/20 dark:border-[var(--color-dash-muted)]/30 overflow-hidden"
419
+ className="fixed z-[9999] bottom-6 right-6 w-[460px] h-[720px] flex flex-col rounded-2xl shadow-2xl border border-[var(--color-dash-label)]/20 dark:border-[var(--color-dash-muted)]/30 overflow-hidden"
287
420
  style={{
288
421
  animation: "agent-panel-in 0.25s cubic-bezier(0.16, 1, 0.3, 1)",
289
422
  }}
@@ -296,9 +429,9 @@ export default function AgentPanel() {
296
429
  </div>
297
430
  <div>
298
431
  <h3 className="text-sm font-semibold text-white">
299
- Partner Hub Agent
432
+ {agent.name}
300
433
  </h3>
301
- <p className="text-xs text-white/60">Agentforce · Online</p>
434
+ <p className="text-xs text-white/60">{agent.subtitle}</p>
302
435
  </div>
303
436
  </div>
304
437
  <button
@@ -317,17 +450,23 @@ export default function AgentPanel() {
317
450
  <SparklesIcon className="h-6 w-6 text-[var(--color-dash-accent)]" />
318
451
  </div>
319
452
  <p className="text-sm text-[var(--color-dash-muted)] dark:text-[var(--color-dash-label)]">
320
- Eva is connecting...
453
+ Connecting...
321
454
  </p>
322
455
  </div>
323
456
  )}
324
- {messages.map((msg) =>
325
- msg.role === "agent" ? (
326
- <AgentMessage key={msg.id} text={msg.text} timestamp={msg.timestamp} />
327
- ) : (
328
- <UserMessage key={msg.id} text={msg.text} timestamp={msg.timestamp} />
329
- )
330
- )}
457
+ {messages.map((msg) => {
458
+ const handoff = handoffs.find(h => h.afterMessageId === msg.id);
459
+ return (
460
+ <React.Fragment key={msg.id}>
461
+ {msg.role === "agent" ? (
462
+ <AgentMessage text={msg.text} timestamp={msg.timestamp} />
463
+ ) : (
464
+ <UserMessage text={msg.text} timestamp={msg.timestamp} />
465
+ )}
466
+ {handoff && <HandoffBanner fromAgent={handoff.from} toAgent={handoff.to} />}
467
+ </React.Fragment>
468
+ );
469
+ })}
331
470
  {isTyping && <TypingIndicator />}
332
471
  <div ref={messagesEndRef} />
333
472
  </div>