FN-6599: preserve chat thread during streaming attach
Keep existing chat history visible when attaching to an in-flight streaming response. - Commit attach-triggered message loads for the streaming session before active session refs settle. - Avoid clearing cached main chat messages on attach cache misses while fetching prior thread history. - Add regression coverage for main chat and QuickChat streaming thread visibility, plus quarantine ledger updates. Files changed: .changeset/fn-6599-chat-streaming-thread.md | 5 + docs/architecture.md | 1 + docs/dashboard-guide.md | 2 +- packages/core/vitest.config.ts | 1 + .../__tests__/ChatView.streaming-thread.test.tsx | 168 +++++++++++++++++++++ .../dashboard/app/hooks/__tests__/useChat.test.ts | 51 +++++++ .../app/hooks/__tests__/useQuickChat.test.ts | 39 +++++ packages/dashboard/app/hooks/useChat.ts | 37 +++-- packages/dashboard/app/hooks/useQuickChat.ts | 27 ++-- scripts/lib/test-quarantine.json | 5 + 10 files changed, 307 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-6599 Fusion-Task-Lineage: b2c94391-6a5b-4048-836d-e4bc56e16786
This commit is contained in:
5
.changeset/fn-6599-chat-streaming-thread.md
Normal file
5
.changeset/fn-6599-chat-streaming-thread.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Keep previously persisted main-chat conversation messages visible while reconnecting to an in-flight assistant response.
|
||||||
@@ -314,6 +314,7 @@ Intentional exclusions from shared snapshots:
|
|||||||
- `ChatManager.sendMessage()` updates that snapshot during streaming (debounced) and clears it on done/error/cancel so stale partial state does not survive completion.
|
- `ChatManager.sendMessage()` updates that snapshot during streaming (debounced) and clears it on done/error/cancel so stale partial state does not survive completion.
|
||||||
- When the active session is still generating after reload/reconnect (`isGenerating: true`), `useChat`/`useQuickChat` hydrate the UI from `inFlightGeneration` immediately, then reconnect `/api/chat/sessions/:id/stream` with `Last-Event-ID = replayFromEventId` to avoid re-appending already-known deltas.
|
- When the active session is still generating after reload/reconnect (`isGenerating: true`), `useChat`/`useQuickChat` hydrate the UI from `inFlightGeneration` immediately, then reconnect `/api/chat/sessions/:id/stream` with `Last-Event-ID = replayFromEventId` to avoid re-appending already-known deltas.
|
||||||
- Hooks also auto-reattach if a stale cached session is selected and a later refresh (or session re-fetch) flips `isGenerating` to true with an `inFlightGeneration` snapshot; dedupe is guarded by a last-attached `(sessionId, replayFromEventId)` ref so snapshot checkpoint bumps do not open duplicate SSE streams.
|
- Hooks also auto-reattach if a stale cached session is selected and a later refresh (or session re-fetch) flips `isGenerating` to true with an `inFlightGeneration` snapshot; dedupe is guarded by a last-attached `(sessionId, replayFromEventId)` ref so snapshot checkpoint bumps do not open duplicate SSE streams.
|
||||||
|
- Attach-triggered message loads may commit the persisted transcript when they match the last attached generation even if React has not yet settled the active-session state/ref. Cache misses during that attach path must preserve the already visible thread so prior user/assistant messages remain visible beside the live streaming assistant response.
|
||||||
- Chat message submission uses SSE streaming responses from dashboard chat routes.
|
- Chat message submission uses SSE streaming responses from dashboard chat routes.
|
||||||
- Direct-chat terminal failures now persist as a distinct assistant message with `metadata.failureInfo` (`summary`, optional `errorClass`, optional `code`, optional `detail`, optional reference metadata) so the chat thread remains the durable primary failure surface after reload/reconnect.
|
- Direct-chat terminal failures now persist as a distinct assistant message with `metadata.failureInfo` (`summary`, optional `errorClass`, optional `code`, optional `detail`, optional reference metadata) so the chat thread remains the durable primary failure surface after reload/reconnect.
|
||||||
- `ChatManager.sendMessage()` preserves any interrupted partial assistant output as its own message, then appends a separate persisted failure bubble instead of overwriting the partial reply.
|
- `ChatManager.sendMessage()` preserves any interrupted partial assistant output as its own message, then appends a separate persisted failure bubble instead of overwriting the partial reply.
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ Chat view provides project-scoped conversations with agents.
|
|||||||
- Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model
|
- Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model
|
||||||
- On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome.
|
- On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome.
|
||||||
- Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately)
|
- Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately)
|
||||||
- In-progress assistant responses now survive refresh/navigation while generation is still active: Chat restores the last durable in-flight text/thinking/tool state immediately, then resumes streaming from the stored replay point instead of starting from an empty "Connecting…" placeholder.
|
- In-progress assistant responses now survive refresh/navigation while generation is still active: Chat restores the last durable in-flight text/thinking/tool state immediately, keeps the prior persisted conversation visible, then resumes streaming from the stored replay point instead of starting from an empty "Connecting…" placeholder.
|
||||||
- If a regular Chat stream drops with a hidden-tab/browser-suspension error (for example `Load failed`) while the server is still generating, Chat suppresses the false error banner, re-attaches to the in-progress stream using the durable replay state, and reconciles the final assistant reply when generation completes.
|
- If a regular Chat stream drops with a hidden-tab/browser-suspension error (for example `Load failed`) while the server is still generating, Chat suppresses the false error banner, re-attaches to the in-progress stream using the durable replay state, and reconciles the final assistant reply when generation completes.
|
||||||
- If you queue a follow-up user message while the assistant is still streaming, Chat now persists that queued text per session so leaving and returning to the view still restores and sends it once the active response finishes.
|
- If you queue a follow-up user message while the assistant is still streaming, Chat now persists that queued text per session so leaving and returning to the view still restores and sends it once the active response finishes.
|
||||||
- Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail.
|
- Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { computeMaxWorkers } from "./src/__test-utils__/vitest-workers";
|
|||||||
const maxWorkers = computeMaxWorkers();
|
const maxWorkers = computeMaxWorkers();
|
||||||
|
|
||||||
const quarantinedCoreTests = [
|
const quarantinedCoreTests = [
|
||||||
|
"src/__tests__/task-list-format.test.ts",
|
||||||
/*
|
/*
|
||||||
FNXC:CoreTests 2026-06-13-17:43:
|
FNXC:CoreTests 2026-06-13-17:43:
|
||||||
The full workspace suite must not fail on suite-load-sensitive tests that pass standalone or only fail after excessive wall time. Quarantine observed core offenders after package-lane hook timeouts instead of appeasing them with wider hook timeouts.
|
The full workspace suite must not fail on suite-load-sensitive tests that pass standalone or only fail after excessive wall time. Quarantine observed core offenders after package-lane hook timeouts instead of appeasing them with wider hook timeouts.
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ChatView } from "../ChatView";
|
||||||
|
import type { ChatMessage, ChatSession } from "@fusion/core";
|
||||||
|
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||||
|
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../utils/projectStorage", () => ({
|
||||||
|
getScopedItem: vi.fn(),
|
||||||
|
setScopedItem: vi.fn(),
|
||||||
|
removeScopedItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../sse-bus", () => ({
|
||||||
|
subscribeSse: vi.fn(() => () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchChatSessions: vi.fn(),
|
||||||
|
fetchChatSession: vi.fn(),
|
||||||
|
createChatSession: vi.fn(),
|
||||||
|
fetchChatMessages: vi.fn(),
|
||||||
|
updateChatSession: vi.fn(),
|
||||||
|
deleteChatSession: vi.fn(),
|
||||||
|
streamChatResponse: vi.fn(),
|
||||||
|
attachChatStream: vi.fn(),
|
||||||
|
cancelChatResponse: vi.fn(),
|
||||||
|
fetchAgents: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
|
||||||
|
]),
|
||||||
|
fetchModels: vi.fn().mockResolvedValue({
|
||||||
|
models: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }],
|
||||||
|
favoriteProviders: [],
|
||||||
|
favoriteModels: [],
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModelId: "claude-sonnet-4-5",
|
||||||
|
}),
|
||||||
|
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useChatRooms", () => ({
|
||||||
|
useChatRooms: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import * as apiModule from "../../api";
|
||||||
|
import * as projectStorageModule from "../../utils/projectStorage";
|
||||||
|
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||||
|
|
||||||
|
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
||||||
|
const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession);
|
||||||
|
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
||||||
|
const mockAttachChatStream = vi.mocked(apiModule.attachChatStream);
|
||||||
|
const mockGetScopedItem = vi.mocked(projectStorageModule.getScopedItem);
|
||||||
|
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||||
|
|
||||||
|
const defaultRoomsState: UseChatRoomsResult = {
|
||||||
|
rooms: [],
|
||||||
|
roomsLoading: false,
|
||||||
|
roomsError: null,
|
||||||
|
activeRoom: null,
|
||||||
|
activeRoomMembers: [],
|
||||||
|
messages: [],
|
||||||
|
messagesLoading: false,
|
||||||
|
selectRoom: vi.fn(),
|
||||||
|
createRoom: vi.fn(),
|
||||||
|
deleteRoom: vi.fn(),
|
||||||
|
sendRoomMessage: vi.fn(),
|
||||||
|
refreshRooms: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agentId">): ChatSession {
|
||||||
|
return {
|
||||||
|
id: overrides.id,
|
||||||
|
agentId: overrides.agentId,
|
||||||
|
status: overrides.status ?? "active",
|
||||||
|
title: overrides.title ?? null,
|
||||||
|
projectId: overrides.projectId ?? null,
|
||||||
|
modelProvider: overrides.modelProvider ?? null,
|
||||||
|
modelId: overrides.modelId ?? null,
|
||||||
|
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
|
||||||
|
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
|
||||||
|
isGenerating: overrides.isGenerating,
|
||||||
|
inFlightGeneration: overrides.inFlightGeneration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" | "sessionId" | "role" | "content">): ChatMessage {
|
||||||
|
return {
|
||||||
|
id: overrides.id,
|
||||||
|
sessionId: overrides.sessionId,
|
||||||
|
role: overrides.role,
|
||||||
|
content: overrides.content,
|
||||||
|
thinkingOutput: overrides.thinkingOutput ?? null,
|
||||||
|
metadata: overrides.metadata ?? null,
|
||||||
|
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FN-6599 ChatView streaming prior thread", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
mockUseChatRooms.mockReturnValue(defaultRoomsState);
|
||||||
|
mockGetScopedItem.mockReturnValue(undefined);
|
||||||
|
mockFetchChatSession.mockResolvedValue({ session: makeSession({ id: "session-001", agentId: "agent-001" }) });
|
||||||
|
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["desktop", 1280],
|
||||||
|
["mobile", 390],
|
||||||
|
])("FN-6599 renders the restored main-chat prior thread while the assistant bubble streams on %s", async (_label, width) => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
const generatingSession = makeSession({
|
||||||
|
id: "session-restored-streaming",
|
||||||
|
agentId: "agent-001",
|
||||||
|
title: "Restored streaming",
|
||||||
|
isGenerating: true,
|
||||||
|
inFlightGeneration: {
|
||||||
|
status: "generating" as const,
|
||||||
|
streamingText: "live partial response",
|
||||||
|
streamingThinking: "thinking",
|
||||||
|
toolCalls: [],
|
||||||
|
replayFromEventId: 101,
|
||||||
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const priorThreadNewestFirst = [
|
||||||
|
makeMessage({ id: "msg-004", sessionId: generatingSession.id, role: "assistant", content: "Second answer" }),
|
||||||
|
makeMessage({ id: "msg-003", sessionId: generatingSession.id, role: "user", content: "Second question" }),
|
||||||
|
makeMessage({ id: "msg-002", sessionId: generatingSession.id, role: "assistant", content: "First answer" }),
|
||||||
|
makeMessage({ id: "msg-001", sessionId: generatingSession.id, role: "user", content: "First question" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
|
||||||
|
mockFetchChatSessions.mockResolvedValue({ sessions: [generatingSession] });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: priorThreadNewestFirst });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("live partial response")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("First question")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("First answer")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Second question")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Second answer")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2973,6 +2973,57 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-6599 keeps restored main-chat prior thread visible during selectSession recovery attach", async () => {
|
||||||
|
const generatingSession = {
|
||||||
|
...makeSession({
|
||||||
|
id: "session-restore-generating",
|
||||||
|
agentId: "agent-001",
|
||||||
|
title: "Restored generating",
|
||||||
|
}),
|
||||||
|
isGenerating: true,
|
||||||
|
inFlightGeneration: {
|
||||||
|
status: "generating" as const,
|
||||||
|
streamingText: "restored partial",
|
||||||
|
streamingThinking: "thinking",
|
||||||
|
toolCalls: [],
|
||||||
|
replayFromEventId: 101,
|
||||||
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const priorThreadNewestFirst = [
|
||||||
|
makeMessage({ id: "msg-004", sessionId: generatingSession.id, role: "assistant", content: "Second answer" }),
|
||||||
|
makeMessage({ id: "msg-003", sessionId: generatingSession.id, role: "user", content: "Second question" }),
|
||||||
|
makeMessage({ id: "msg-002", sessionId: generatingSession.id, role: "assistant", content: "First answer" }),
|
||||||
|
makeMessage({ id: "msg-001", sessionId: generatingSession.id, role: "user", content: "First question" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
|
||||||
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] });
|
||||||
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
expect(result.current.streamingText).toBe("restored partial");
|
||||||
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
||||||
|
generatingSession.id,
|
||||||
|
expect.any(Object),
|
||||||
|
"proj-123",
|
||||||
|
{ lastEventId: 101 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||||
|
"First question",
|
||||||
|
"First answer",
|
||||||
|
"Second question",
|
||||||
|
"Second answer",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("FN-6496 loads prior thread during chat:session:updated in-flight attach", async () => {
|
it("FN-6496 loads prior thread during chat:session:updated in-flight attach", async () => {
|
||||||
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Existing" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Existing" });
|
||||||
const priorThreadNewestFirst = [
|
const priorThreadNewestFirst = [
|
||||||
|
|||||||
@@ -2327,6 +2327,45 @@ describe("useQuickChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-6599 keeps QuickChat prior thread visible when selectSession attaches before active ref settles", async () => {
|
||||||
|
const session = {
|
||||||
|
...makeSession({ id: "session-select-generating", agentId: "agent-001" }),
|
||||||
|
isGenerating: true,
|
||||||
|
inFlightGeneration: {
|
||||||
|
status: "generating" as const,
|
||||||
|
streamingText: "quick partial",
|
||||||
|
streamingThinking: "thinking",
|
||||||
|
toolCalls: [],
|
||||||
|
replayFromEventId: 22,
|
||||||
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const priorThreadNewestFirst = [
|
||||||
|
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
||||||
|
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
||||||
|
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
||||||
|
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
||||||
|
];
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: priorThreadNewestFirst });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.selectSession(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
expect(result.current.streamingText).toBe("quick partial");
|
||||||
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||||
|
"First question",
|
||||||
|
"First answer",
|
||||||
|
"Second question",
|
||||||
|
"Second answer",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("FN-6496 loads prior thread when initializing a generating QuickChat session", async () => {
|
it("FN-6496 loads prior thread when initializing a generating QuickChat session", async () => {
|
||||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||||
const priorThreadNewestFirst = [
|
const priorThreadNewestFirst = [
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ export function useChat(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hydrateMessagesFromCache = useCallback(
|
const hydrateMessagesFromCache = useCallback(
|
||||||
(sessionId?: string | null) => {
|
(sessionId?: string | null, opts?: { clearOnMiss?: boolean }) => {
|
||||||
const cachedMessages = readCachedMessages(projectId, sessionId);
|
const cachedMessages = readCachedMessages(projectId, sessionId);
|
||||||
if (cachedMessages.length > 0) {
|
if (cachedMessages.length > 0) {
|
||||||
setMessages(cachedMessages);
|
setMessages(cachedMessages);
|
||||||
@@ -446,7 +446,9 @@ export function useChat(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages([]);
|
if (opts?.clearOnMiss !== false) {
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[projectId, readCachedMessages],
|
[projectId, readCachedMessages],
|
||||||
@@ -454,7 +456,7 @@ export function useChat(
|
|||||||
|
|
||||||
// Load messages when active session changes
|
// Load messages when active session changes
|
||||||
const loadMessages = useCallback(
|
const loadMessages = useCallback(
|
||||||
async (sessionId: string, opts?: { offset?: number; before?: string }) => {
|
async (sessionId: string, opts?: { offset?: number; before?: string; commitForStreamingAttach?: boolean }) => {
|
||||||
const isPaginationRequest = (typeof opts?.offset === "number" && opts.offset > 0) || typeof opts?.before === "string";
|
const isPaginationRequest = (typeof opts?.offset === "number" && opts.offset > 0) || typeof opts?.before === "string";
|
||||||
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
|
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
|
||||||
const cachedMessages = !isPaginationRequest ? readCachedMessages(projectId, sessionId) : [];
|
const cachedMessages = !isPaginationRequest ? readCachedMessages(projectId, sessionId) : [];
|
||||||
@@ -471,13 +473,15 @@ export function useChat(
|
|||||||
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc", ...opts }, projectId);
|
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc", ...opts }, projectId);
|
||||||
// API returns newest-first (order=desc); reverse so display is oldest-first.
|
// API returns newest-first (order=desc); reverse so display is oldest-first.
|
||||||
const mappedMessages = data.messages.slice().reverse().map(mapChatMessageToInfo);
|
const mappedMessages = data.messages.slice().reverse().map(mapChatMessageToInfo);
|
||||||
|
const shouldCommitMessages = activeSessionRef.current?.id === sessionId
|
||||||
|
|| (opts?.commitForStreamingAttach === true && lastAttachedGenerationRef.current?.sessionId === sessionId);
|
||||||
if (isPaginationRequest) {
|
if (isPaginationRequest) {
|
||||||
if (activeSessionRef.current?.id === sessionId) {
|
if (shouldCommitMessages) {
|
||||||
setMessages((prev) => [...mappedMessages, ...prev]);
|
setMessages((prev) => [...mappedMessages, ...prev]);
|
||||||
setHasMoreMessages(data.messages.length >= 50);
|
setHasMoreMessages(data.messages.length >= 50);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (activeSessionRef.current?.id === sessionId) {
|
if (shouldCommitMessages) {
|
||||||
setMessages(mappedMessages);
|
setMessages(mappedMessages);
|
||||||
setHasMoreMessages(data.messages.length >= 50);
|
setHasMoreMessages(data.messages.length >= 50);
|
||||||
if (cacheKey) writeCache(cacheKey, mappedMessages, { maxBytes: 500_000 });
|
if (cacheKey) writeCache(cacheKey, mappedMessages, { maxBytes: 500_000 });
|
||||||
@@ -537,14 +541,20 @@ export function useChat(
|
|||||||
cancelledByUserRef.current = false;
|
cancelledByUserRef.current = false;
|
||||||
const currentMessages = messagesRef.current;
|
const currentMessages = messagesRef.current;
|
||||||
const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
|
const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
|
||||||
|
lastAttachedGenerationRef.current = {
|
||||||
|
sessionId,
|
||||||
|
replayFromEventId: typeof inFlightGeneration?.replayFromEventId === "number"
|
||||||
|
? inFlightGeneration.replayFromEventId
|
||||||
|
: null,
|
||||||
|
};
|
||||||
if (needsPriorThreadLoad && !options?.priorThreadLoadAlreadyStarted) {
|
if (needsPriorThreadLoad && !options?.priorThreadLoadAlreadyStarted) {
|
||||||
/*
|
/*
|
||||||
FNXC:ChatStreaming 2026-06-16-18:10:
|
FNXC:ChatStreaming 2026-06-17-16:50:
|
||||||
In-flight attach must keep the persisted prior thread visible while the assistant bubble streams.
|
Main chat must keep the persisted prior thread visible while an assistant response streams, including attach paths that run before React commits activeSession into activeSessionRef.
|
||||||
The chat:message:added SSE echo is suppressed during streaming to avoid duplicate local bubbles, so attach has to hydrate cached history and start a thread load itself when messages are empty or from another session.
|
Because chat:message:added echoes are suppressed during streaming, attach-triggered thread loads must commit for the attached session and cache misses must not blank an existing thread while the load is in flight.
|
||||||
*/
|
*/
|
||||||
hydrateMessagesFromCache(sessionId);
|
hydrateMessagesFromCache(sessionId, { clearOnMiss: false });
|
||||||
void loadMessages(sessionId);
|
void loadMessages(sessionId, { commitForStreamingAttach: true });
|
||||||
}
|
}
|
||||||
if (inFlightGeneration) {
|
if (inFlightGeneration) {
|
||||||
setStreamingText(inFlightGeneration.streamingText);
|
setStreamingText(inFlightGeneration.streamingText);
|
||||||
@@ -610,12 +620,6 @@ export function useChat(
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
streamRef.current = stream;
|
streamRef.current = stream;
|
||||||
lastAttachedGenerationRef.current = {
|
|
||||||
sessionId,
|
|
||||||
replayFromEventId: typeof inFlightGeneration?.replayFromEventId === "number"
|
|
||||||
? inFlightGeneration.replayFromEventId
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
return true;
|
return true;
|
||||||
}, [addToast, hydrateMessagesFromCache, loadMessages, projectId, flushPendingMessage]);
|
}, [addToast, hydrateMessagesFromCache, loadMessages, projectId, flushPendingMessage]);
|
||||||
|
|
||||||
@@ -636,6 +640,7 @@ export function useChat(
|
|||||||
// Find and set active session
|
// Find and set active session
|
||||||
const session = sessionOverride ?? sessions.find((s) => s.id === id);
|
const session = sessionOverride ?? sessions.find((s) => s.id === id);
|
||||||
setActiveSession(session || null);
|
setActiveSession(session || null);
|
||||||
|
activeSessionRef.current = session || null;
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
void fetchChatSession(id, projectId)
|
void fetchChatSession(id, projectId)
|
||||||
|
|||||||
@@ -325,11 +325,13 @@ export function useQuickChat(
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadMessagesForSession = useCallback(async (sessionId: string) => {
|
const loadMessagesForSession = useCallback(async (sessionId: string, opts?: { commitForStreamingAttach?: boolean }) => {
|
||||||
setMessagesLoading(true);
|
setMessagesLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId);
|
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId);
|
||||||
if (activeSessionRef.current?.id === sessionId) {
|
const shouldCommitMessages = activeSessionRef.current?.id === sessionId
|
||||||
|
|| (opts?.commitForStreamingAttach === true && lastAttachedGenerationRef.current?.sessionId === sessionId);
|
||||||
|
if (shouldCommitMessages) {
|
||||||
setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
|
setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -351,13 +353,19 @@ export function useQuickChat(
|
|||||||
cancelledByUserRef.current = false;
|
cancelledByUserRef.current = false;
|
||||||
const currentMessages = messagesRef.current;
|
const currentMessages = messagesRef.current;
|
||||||
const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
|
const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
|
||||||
|
lastAttachedGenerationRef.current = {
|
||||||
|
sessionId,
|
||||||
|
replayFromEventId: typeof inFlightGeneration?.replayFromEventId === "number"
|
||||||
|
? inFlightGeneration.replayFromEventId
|
||||||
|
: null,
|
||||||
|
};
|
||||||
if (needsPriorThreadLoad) {
|
if (needsPriorThreadLoad) {
|
||||||
/*
|
/*
|
||||||
FNXC:ChatStreaming 2026-06-16-18:16:
|
FNXC:ChatStreaming 2026-06-17-16:58:
|
||||||
QuickChat has the same streaming visibility contract as the full chat view: a resumed in-flight assistant bubble must not hide prior user turns or assistant responses.
|
QuickChat mirrors main chat: a resumed in-flight assistant bubble must not hide prior user turns or assistant responses, even when attach runs before activeSessionRef observes the selected session.
|
||||||
Because QuickChat has no message cache and streaming suppresses persisted echo handling, attach fetches the session thread directly by id instead of relying on activeSession-bound loaders that may see stale state.
|
Because streaming suppresses persisted echo handling, attach-triggered thread loads commit for the attached session instead of depending only on activeSession-bound state.
|
||||||
*/
|
*/
|
||||||
void loadMessagesForSession(sessionId);
|
void loadMessagesForSession(sessionId, { commitForStreamingAttach: true });
|
||||||
}
|
}
|
||||||
if (inFlightGeneration) {
|
if (inFlightGeneration) {
|
||||||
setStreamingText(inFlightGeneration.streamingText);
|
setStreamingText(inFlightGeneration.streamingText);
|
||||||
@@ -414,12 +422,6 @@ export function useQuickChat(
|
|||||||
? { lastEventId: inFlightGeneration.replayFromEventId }
|
? { lastEventId: inFlightGeneration.replayFromEventId }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
lastAttachedGenerationRef.current = {
|
|
||||||
sessionId,
|
|
||||||
replayFromEventId: typeof inFlightGeneration?.replayFromEventId === "number"
|
|
||||||
? inFlightGeneration.replayFromEventId
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
return true;
|
return true;
|
||||||
}, [addToast, loadMessagesForSession, flushPendingMessage, t, projectId]);
|
}, [addToast, loadMessagesForSession, flushPendingMessage, t, projectId]);
|
||||||
|
|
||||||
@@ -632,6 +634,7 @@ export function useQuickChat(
|
|||||||
|
|
||||||
resetTransientComposerState();
|
resetTransientComposerState();
|
||||||
setActiveSession(session);
|
setActiveSession(session);
|
||||||
|
activeSessionRef.current = session;
|
||||||
|
|
||||||
void Promise.resolve(fetchChatSession(session.id, projectId))
|
void Promise.resolve(fetchChatSession(session.id, projectId))
|
||||||
.then(({ session: refreshedSession }) => {
|
.then(({ session: refreshedSession }) => {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
"file": "packages/core/src/__tests__/test-project.test.ts",
|
"file": "packages/core/src/__tests__/test-project.test.ts",
|
||||||
"reason": "FN-6596 verification: pnpm test failed in the broad changed-package @fusion/core lane with a test timeout in test-project after the merge gate had passed; immediate isolated rerun of the file passed. Quarantined as a suite-load timeout flake without timeout bumps, retries, or assertion loosening.",
|
"reason": "FN-6596 verification: pnpm test failed in the broad changed-package @fusion/core lane with a test timeout in test-project after the merge gate had passed; immediate isolated rerun of the file passed. Quarantined as a suite-load timeout flake without timeout bumps, retries, or assertion loosening.",
|
||||||
"quarantinedAt": "2026-06-17"
|
"quarantinedAt": "2026-06-17"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "packages/core/src/__tests__/task-list-format.test.ts",
|
||||||
|
"reason": "FN-6599 broad pnpm test: @fusion/core package lane timed out in the file beforeAll hook after the merge gate and impacted dashboard tests had passed; immediate file-specific rerun passed. Classified as unrelated package-lane hook-timeout flake and quarantined without timeout bumps or assertion changes.",
|
||||||
|
"quarantinedAt": "2026-06-17"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user