fix(ui): recover Lighthouse chat stream state

This commit is contained in:
alejandrobailo
2026-06-30 17:20:44 +02:00
parent a91e21deb2
commit 599d962c0a
5 changed files with 58 additions and 31 deletions
@@ -442,13 +442,16 @@ describe("LighthouseV2ChatPage", () => {
const recordCurrentUrl = () => {
notifiedUrls.push(`${window.location.pathname}${window.location.search}`);
};
window.addEventListener(
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
recordCurrentUrl,
);
renderPage();
try {
// Register inside the try so a throw in renderPage() can't leak the
// listener into later tests (the finally only runs if we entered the try).
window.addEventListener(
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
recordCurrentUrl,
);
renderPage();
// When
await user.type(
screen.getByRole("textbox", { name: "Message" }),
@@ -67,6 +67,7 @@ interface LighthouseV2ChatPageProps {
initialActiveTaskId?: string | null;
initialStreamUrl?: string;
initialPrompt?: string;
initialError?: string;
}
export function LighthouseV2ChatPage({
@@ -78,6 +79,7 @@ export function LighthouseV2ChatPage({
initialActiveTaskId,
initialStreamUrl,
initialPrompt,
initialError,
}: LighthouseV2ChatPageProps) {
const eventSourceRef = useRef<EventSource | null>(null);
const initialPromptSentRef = useRef(false);
@@ -96,7 +98,7 @@ export function LighthouseV2ChatPage({
);
const [messages, setMessages] = useState(initialMessages);
const [input, setInput] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
const [feedback, setFeedback] = useState<string | null>(initialError ?? null);
const [blockedByConflict, setBlockedByConflict] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [lastSubmittedText, setLastSubmittedText] = useState<string | null>(
@@ -132,7 +132,7 @@ describe("event-reducer", () => {
expect(next.activeTaskId).toBeNull();
});
it("should mark disconnect as recoverable", () => {
it("should clear the task gate on disconnect so retry can recover", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
@@ -141,6 +141,8 @@ describe("event-reducer", () => {
// Then
expect(next.status).toBe("disconnected");
expect(next.activeTaskId).toBe("task-1");
// activeTaskId must be cleared: leaving it set keeps canSend false and
// makes the Retry button a no-op after a dropped SSE connection.
expect(next.activeTaskId).toBeNull();
});
});
@@ -49,6 +49,11 @@ export type LighthouseV2StreamActivityItem =
| LighthouseV2StreamTextActivityItem
| LighthouseV2StreamToolCallActivityItem;
export interface LighthouseV2StreamError {
code: string;
detail: string;
}
export interface LighthouseV2StreamState {
status: LighthouseV2StreamStatus;
activeTaskId: string | null;
@@ -56,10 +61,7 @@ export interface LighthouseV2StreamState {
toolCalls: LighthouseV2ToolCallState[];
activityItems: LighthouseV2StreamActivityItem[];
messageId?: string;
error?: {
code: string;
detail: string;
};
error?: LighthouseV2StreamError;
}
export function createInitialLighthouseV2StreamState(
@@ -151,9 +153,12 @@ export function reduceLighthouseV2Event(
},
};
case LIGHTHOUSE_V2_SSE_EVENT.DISCONNECT:
// Clear the task gate so the UI can recover: keeping activeTaskId set
// leaves canSend false and makes the Retry button a no-op.
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED,
activeTaskId: null,
};
}
}
+34 -19
View File
@@ -58,34 +58,50 @@ export default async function AIChatbot({
const result = await getLighthouseV2SupportedModels(
configuration.providerType,
);
return [
configuration.providerType,
"data" in result ? result.data : [],
] as const satisfies readonly [
LighthouseV2ProviderType,
LighthouseV2SupportedModel[],
];
return [configuration.providerType, result] as const;
}),
);
const modelsByProvider = Object.fromEntries(modelsEntries) as Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
const modelsByProvider = Object.fromEntries(
modelsEntries.map(([providerType, result]) => [
providerType,
"data" in result ? result.data : [],
]),
) as Record<LighthouseV2ProviderType, LighthouseV2SupportedModel[]>;
// Surface (rather than silently swallow to []) providers whose models
// failed to load, so an empty model list reads as a real backend failure.
const failedModelProviders = modelsEntries
.filter(([, result]) => !("data" in result))
.map(([providerType]) => providerType);
const modelsError =
failedModelProviders.length > 0
? `Could not load available models for: ${failedModelProviders.join(", ")}. Try again shortly.`
: undefined;
const [initialMessages, activeSession] = activeSessionId
? await Promise.all([
getLighthouseV2Messages(activeSessionId),
getLighthouseV2Session(activeSessionId),
])
: [{ data: [] }, undefined];
// Treat the ?session= id as valid when its messages load (you can't fetch
// messages for a non-existent session, so this is the authoritative
// "session exists" check). A stale/deleted id fails here and is dropped so
// the client starts fresh instead of sending against a dead session. The
// session-metadata read stays best-effort — it only supplies activeTaskId,
// so a flaky metadata read must NOT discard an otherwise-valid session.
const sessionLoaded = Boolean(activeSessionId) && "data" in initialMessages;
const validSessionId = sessionLoaded ? activeSessionId : undefined;
const chatMessages =
sessionLoaded && "data" in initialMessages ? initialMessages.data : [];
const initialActiveTaskId =
activeSession && "data" in activeSession
sessionLoaded && activeSession && "data" in activeSession
? (activeSession.data.activeTaskId ?? null)
: null;
const initialStreamUrl =
activeSessionId && initialActiveTaskId
? buildLighthouseV2StreamUrl(activeSessionId)
validSessionId && initialActiveTaskId
? buildLighthouseV2StreamUrl(validSessionId)
: undefined;
const chatRouteKey = activeSessionId ?? initialPrompt ?? "new";
const chatRouteKey = validSessionId ?? initialPrompt ?? "new";
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
@@ -96,13 +112,12 @@ export default async function AIChatbot({
configurations={configurations}
modelsByProvider={modelsByProvider}
supportedProviders={supportedProviders}
initialSessionId={activeSessionId}
initialMessages={
"data" in initialMessages ? initialMessages.data : []
}
initialSessionId={validSessionId}
initialMessages={chatMessages}
initialActiveTaskId={initialActiveTaskId}
initialStreamUrl={initialStreamUrl}
initialPrompt={initialPrompt}
initialError={modelsError}
/>
</div>
</ContentLayout>