Skip to content

Commit f71f5b7

Browse files
committed
fix: wire Agents page specialist chat buttons with direct session creation
Replace the fragile hash-based useEffect chain for specialist session creation on the Agents page with a direct callback pattern. The previous approach relied on setting window.location.hash then reacting via a useEffect with a ref-based mutex, which was unreliable under React StrictMode double-invocation — causing non-CMO specialist buttons to silently fail at runtime. The new handleSpecialistChat callback in App creates the session directly via client.createSession({ specialistId }), sets activeSessionId and currentSpecialistId, then navigates to #chat. This ensures the ChatWorkspace immediately has an active session with no timing gap. The hash-based auto-creation effect is preserved for Dashboard specialist cards and handoff chip navigation which use window.location.hash directly.
1 parent ad18d9e commit f71f5b7

File tree

3 files changed

+244
-1
lines changed

3 files changed

+244
-1
lines changed

apps/desktop/src/app/App.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,36 @@ export function App() {
417417
[],
418418
);
419419

420+
// Directly create a specialist session and navigate to the chat view.
421+
// This is called from the Agents page specialist buttons and avoids the
422+
// fragile effect-based approach that was unreliable with React StrictMode
423+
// double-invocation (the ref-based mutex could get stuck, causing non-CMO
424+
// specialist buttons to silently fail at runtime).
425+
const handleSpecialistChat = useCallback(
426+
async (specialistId: string) => {
427+
if (!client || !activeAgentId) {
428+
return;
429+
}
430+
try {
431+
const session = await client.createSession({
432+
agentId: activeAgentId,
433+
specialistId,
434+
});
435+
setSessions((prev) => [session, ...prev]);
436+
setActiveSessionId(session.id);
437+
setCurrentSpecialistId(specialistId);
438+
window.location.hash = "#chat";
439+
} catch (error) {
440+
console.error("Failed to create specialist session", error);
441+
toast.error("Failed to start specialist chat. Please try again.");
442+
}
443+
},
444+
[client, activeAgentId],
445+
);
446+
420447
// Auto-create a specialist session when navigating to #chat?specialist=<id>
448+
// via hash-based navigation (e.g. dashboard specialist cards, handoff chips).
449+
// For the Agents page, handleSpecialistChat above is used instead.
421450
const specialistCreatingRef = useRef(false);
422451
useEffect(() => {
423452
if (!currentSpecialistId || !client || !activeAgentId || hashView !== "chat") {
@@ -652,6 +681,7 @@ export function App() {
652681
<SpecialistTeamBrowser
653682
client={client}
654683
agentId={activeAgentId}
684+
onSpecialistChat={handleSpecialistChat}
655685
/>
656686
)}
657687
</div>

apps/desktop/src/features/agents/components/SpecialistTeamBrowser.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ const MAX_OUTPUTS_PER_SPECIALIST = 3;
1212
interface SpecialistTeamBrowserProps {
1313
client: SidecarClient | null;
1414
agentId?: string | undefined;
15+
onSpecialistChat?: ((specialistId: string) => void) | undefined;
1516
}
1617

17-
export function SpecialistTeamBrowser({ client, agentId }: SpecialistTeamBrowserProps) {
18+
export function SpecialistTeamBrowser({ client, agentId, onSpecialistChat }: SpecialistTeamBrowserProps) {
1819
const [specialists, setSpecialists] = useState<SpecialistAgent[]>([]);
1920
const [isLoading, setIsLoading] = useState(true);
2021
const [error, setError] = useState<string | null>(null);
@@ -94,6 +95,13 @@ export function SpecialistTeamBrowser({ client, agentId }: SpecialistTeamBrowser
9495
}, [client, agentId]);
9596

9697
function handleChat(specialistId: string): void {
98+
if (onSpecialistChat) {
99+
// Directly create session and navigate — avoids the fragile
100+
// hash-based effect chain that could fail under StrictMode.
101+
void onSpecialistChat(specialistId);
102+
return;
103+
}
104+
// Fallback to hash-based navigation (used when rendered without callback)
97105
window.location.hash = `#chat?specialist=${encodeURIComponent(specialistId)}`;
98106
}
99107

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { readFileSync } from "node:fs";
4+
import { resolve } from "node:path";
5+
6+
// ---------------------------------------------------------------------------
7+
// Source files under test
8+
// ---------------------------------------------------------------------------
9+
10+
const specialistTeamBrowserSrc = readFileSync(
11+
resolve(import.meta.dirname, "SpecialistTeamBrowser.tsx"),
12+
"utf-8",
13+
);
14+
15+
const specialistCardSrc = readFileSync(
16+
resolve(import.meta.dirname, "SpecialistCard.tsx"),
17+
"utf-8",
18+
);
19+
20+
const appSrc = readFileSync(
21+
resolve(import.meta.dirname, "../../../app/App.tsx"),
22+
"utf-8",
23+
);
24+
25+
// ---------------------------------------------------------------------------
26+
// AC: SpecialistTeamBrowser accepts onSpecialistChat callback prop
27+
// ---------------------------------------------------------------------------
28+
29+
void test("SpecialistTeamBrowser interface includes onSpecialistChat prop", () => {
30+
assert.ok(
31+
specialistTeamBrowserSrc.includes("onSpecialistChat"),
32+
"SpecialistTeamBrowserProps must include onSpecialistChat callback",
33+
);
34+
});
35+
36+
void test("SpecialistTeamBrowser destructures onSpecialistChat from props", () => {
37+
assert.ok(
38+
specialistTeamBrowserSrc.includes("onSpecialistChat }"),
39+
"Component must destructure onSpecialistChat from props",
40+
);
41+
});
42+
43+
// ---------------------------------------------------------------------------
44+
// AC: handleChat uses direct callback when available (not hash navigation)
45+
// ---------------------------------------------------------------------------
46+
47+
void test("handleChat calls onSpecialistChat when provided", () => {
48+
assert.ok(
49+
specialistTeamBrowserSrc.includes("onSpecialistChat(specialistId)") ||
50+
specialistTeamBrowserSrc.includes("onSpecialistChat(specialistId);"),
51+
"handleChat must call onSpecialistChat(specialistId) when the callback is provided",
52+
);
53+
});
54+
55+
void test("handleChat still has hash fallback for backward compatibility", () => {
56+
assert.ok(
57+
specialistTeamBrowserSrc.includes("#chat?specialist="),
58+
"handleChat must retain hash-based navigation as fallback",
59+
);
60+
});
61+
62+
// ---------------------------------------------------------------------------
63+
// AC: App.tsx defines handleSpecialistChat that creates session directly
64+
// ---------------------------------------------------------------------------
65+
66+
void test("App.tsx has handleSpecialistChat callback", () => {
67+
assert.ok(
68+
appSrc.includes("handleSpecialistChat"),
69+
"App must define a handleSpecialistChat callback for direct session creation",
70+
);
71+
});
72+
73+
void test("handleSpecialistChat creates session with specialistId", () => {
74+
// Find the handleSpecialistChat function body
75+
const fnStart = appSrc.indexOf("handleSpecialistChat");
76+
assert.ok(fnStart >= 0, "handleSpecialistChat must exist");
77+
78+
const fnBlock = appSrc.slice(fnStart, fnStart + 600);
79+
assert.ok(
80+
fnBlock.includes("createSession") && fnBlock.includes("specialistId"),
81+
"handleSpecialistChat must call createSession with specialistId",
82+
);
83+
});
84+
85+
void test("handleSpecialistChat sets activeSessionId before navigating", () => {
86+
const fnStart = appSrc.indexOf("handleSpecialistChat");
87+
const fnBlock = appSrc.slice(fnStart, fnStart + 600);
88+
89+
const setActiveIdx = fnBlock.indexOf("setActiveSessionId");
90+
const hashIdx = fnBlock.indexOf('window.location.hash = "#chat"');
91+
assert.ok(setActiveIdx >= 0, "Must call setActiveSessionId");
92+
assert.ok(hashIdx >= 0, "Must navigate to #chat");
93+
assert.ok(
94+
setActiveIdx < hashIdx,
95+
"setActiveSessionId must be called before navigation to ensure session is ready",
96+
);
97+
});
98+
99+
void test("handleSpecialistChat sets currentSpecialistId", () => {
100+
const fnStart = appSrc.indexOf("handleSpecialistChat");
101+
const fnBlock = appSrc.slice(fnStart, fnStart + 600);
102+
103+
assert.ok(
104+
fnBlock.includes("setCurrentSpecialistId"),
105+
"handleSpecialistChat must set currentSpecialistId for chat specialist context",
106+
);
107+
});
108+
109+
// ---------------------------------------------------------------------------
110+
// AC: App.tsx passes onSpecialistChat to SpecialistTeamBrowser
111+
// ---------------------------------------------------------------------------
112+
113+
void test("App.tsx passes onSpecialistChat to SpecialistTeamBrowser", () => {
114+
assert.ok(
115+
appSrc.includes("onSpecialistChat={handleSpecialistChat}"),
116+
"App must pass onSpecialistChat={handleSpecialistChat} to SpecialistTeamBrowser",
117+
);
118+
});
119+
120+
// ---------------------------------------------------------------------------
121+
// AC: SpecialistCard button wiring unchanged — onChat callback for all cards
122+
// ---------------------------------------------------------------------------
123+
124+
void test("SpecialistCard button onClick calls onChat(specialist.id)", () => {
125+
assert.ok(
126+
specialistCardSrc.includes("onClick={() => onChat(specialist.id)}"),
127+
"Button must call onChat(specialist.id) on click — this is unchanged",
128+
);
129+
});
130+
131+
void test("SpecialistTeamBrowser passes onChat to all specialist cards", () => {
132+
// Both manager and operational cards must receive onChat={handleChat}
133+
const occurrences = specialistTeamBrowserSrc.match(/onChat=\{handleChat\}/g);
134+
assert.ok(
135+
occurrences !== null && occurrences.length >= 2,
136+
"onChat={handleChat} must appear at least twice (manager + operational cards)",
137+
);
138+
});
139+
140+
// ---------------------------------------------------------------------------
141+
// AC: All 8 specialist IDs are valid for the navigation chain
142+
// ---------------------------------------------------------------------------
143+
144+
const specialistMetaSrc = readFileSync(
145+
resolve(import.meta.dirname, "../specialist-meta.ts"),
146+
"utf-8",
147+
);
148+
149+
const expectedSpecialists = [
150+
"cmo",
151+
"market-intel",
152+
"positioning",
153+
"website-conversion",
154+
"seo-aeo",
155+
"distribution",
156+
"content",
157+
"outbound",
158+
];
159+
160+
for (const id of expectedSpecialists) {
161+
void test(`specialist "${id}" exists in specialist-meta.ts`, () => {
162+
assert.ok(
163+
specialistMetaSrc.includes(`"${id}":`) || specialistMetaSrc.includes(`${id}:`),
164+
`SPECIALIST_META must define specialist "${id}"`,
165+
);
166+
});
167+
}
168+
169+
// ---------------------------------------------------------------------------
170+
// AC: handleSpecialistChat is a useCallback (avoids stale closure)
171+
// ---------------------------------------------------------------------------
172+
173+
void test("handleSpecialistChat is wrapped in useCallback", () => {
174+
// Check that handleSpecialistChat is defined via useCallback to prevent
175+
// unnecessary re-renders and stale closures
176+
const callbackIdx = appSrc.indexOf("handleSpecialistChat = useCallback");
177+
assert.ok(
178+
callbackIdx >= 0,
179+
"handleSpecialistChat must be wrapped in useCallback",
180+
);
181+
});
182+
183+
// ---------------------------------------------------------------------------
184+
// AC: Error handling exists for session creation failure
185+
// ---------------------------------------------------------------------------
186+
187+
void test("handleSpecialistChat has error handling with user toast", () => {
188+
const fnStart = appSrc.indexOf("handleSpecialistChat");
189+
const fnBlock = appSrc.slice(fnStart, fnStart + 600);
190+
assert.ok(
191+
fnBlock.includes("catch") && fnBlock.includes("toast.error"),
192+
"handleSpecialistChat must catch errors and show toast to user",
193+
);
194+
});
195+
196+
// ---------------------------------------------------------------------------
197+
// AC: Hash-based effect still works for Dashboard and handoff chip navigation
198+
// ---------------------------------------------------------------------------
199+
200+
void test("App.tsx retains the auto-creation effect for hash-based navigation", () => {
201+
assert.ok(
202+
appSrc.includes("specialistCreatingRef"),
203+
"The hash-based auto-creation effect must still exist for Dashboard/handoff navigation",
204+
);
205+
});

0 commit comments

Comments
 (0)