feat(FN-3950): reuse keyboard-aware room thread container (+3 more)
Commits merged: - fix(FN-3950): replace graph task rgba highlights with color tokens - test(FN-3950): add room mobile keyboard anchoring regression coverage - fix(FN-3950): enable mobile keyboard tracking for room threads - feat(FN-3950): complete Step 1 — reuse keyboard-aware room thread container Files changed: docs/dashboard-guide.md | 1 + packages/dashboard/app/components/ChatView.tsx | 11 ++- .../components/__tests__/ChatView.rooms.test.tsx | 88 +++++++++++++++++++++- 3 files changed, 93 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-3950
This commit is contained in:
@@ -112,6 +112,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are
|
|||||||
- Selecting a room opens the room thread pane with loading and empty states, then renders room messages from `rooms.messages` as `ChatMessageInfo` entries in the same thread UI used for direct Chat.
|
- Selecting a room opens the room thread pane with loading and empty states, then renders room messages from `rooms.messages` as `ChatMessageInfo` entries in the same thread UI used for direct Chat.
|
||||||
- Submitting the room composer calls `rooms.sendRoomMessage(...)`, which posts the user message to `POST /api/chat/rooms/:id/messages`.
|
- Submitting the room composer calls `rooms.sendRoomMessage(...)`, which posts the user message to `POST /api/chat/rooms/:id/messages`.
|
||||||
- After a successful room send, the room composer is cleared (matching direct-chat composer behavior) so stale text is not left in the input.
|
- After a successful room send, the room composer is cleared (matching direct-chat composer behavior) so stale text is not left in the input.
|
||||||
|
- On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing.
|
||||||
- The dashboard backend now orchestrates room responders on that POST: mentioned members are routed as direct responders, additional ambient members may reply (up to the room ambient responder cap), and each assistant reply is persisted with `senderAgentId` via `chatStore.addRoomMessage(...)`.
|
- The dashboard backend now orchestrates room responders on that POST: mentioned members are routed as direct responders, additional ambient members may reply (up to the room ambient responder cap), and each assistant reply is persisted with `senderAgentId` via `chatStore.addRoomMessage(...)`.
|
||||||
- The UI still avoids optimistic room echo; it renders both the persisted user message and persisted assistant room replies from `chat:room:message:*` SSE events, so room threads stay server-authoritative.
|
- The UI still avoids optimistic room echo; it renders both the persisted user message and persisted assistant room replies from `chat:room:message:*` SSE events, so room threads stay server-authoritative.
|
||||||
- Relationship summary: direct Chat runs one target (agent or model) per session; rooms are shared threads with multiple agent members and now use the same message contract as direct Chat; Quick Chat stays a floating single-target panel and does not host rooms.
|
- Relationship summary: direct Chat runs one target (agent or model) per session; rooms are shared threads with multiple agent members and now use the same message contract as direct Chat; Quick Chat stays a floating single-target panel and does not host rooms.
|
||||||
|
|||||||
@@ -856,8 +856,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
}
|
}
|
||||||
}, [chatRoomsEnabled, chatScope]);
|
}, [chatRoomsEnabled, chatScope]);
|
||||||
|
|
||||||
|
const roomThreadActive = chatRoomsEnabled && chatScope === "rooms" && !!rooms.activeRoom;
|
||||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||||
enabled: isMobile && !!activeSession,
|
enabled: isMobile && (!!activeSession || roomThreadActive),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only opt into visual-viewport sizing when we have concrete keyboard
|
// Only opt into visual-viewport sizing when we have concrete keyboard
|
||||||
@@ -873,6 +874,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||||
} as CSSProperties)
|
} as CSSProperties)
|
||||||
: {};
|
: {};
|
||||||
|
const threadClassName = `chat-thread${keyboardOpen && hasKeyboardViewportDisplacement ? " chat-thread--keyboard-active" : ""}`;
|
||||||
|
|
||||||
const filteredSkills = useMemo(() => {
|
const filteredSkills = useMemo(() => {
|
||||||
const normalizedFilter = skillFilter.trim().toLowerCase();
|
const normalizedFilter = skillFilter.trim().toLowerCase();
|
||||||
@@ -2081,7 +2083,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
)}
|
)}
|
||||||
{/* Thread */}
|
{/* Thread */}
|
||||||
{chatRoomsEnabled && chatScope === "rooms" ? (
|
{chatRoomsEnabled && chatScope === "rooms" ? (
|
||||||
<div className="chat-thread">
|
<div className={threadClassName} style={threadKeyboardStyle}>
|
||||||
{rooms.activeRoom ? (
|
{rooms.activeRoom ? (
|
||||||
<>
|
<>
|
||||||
<div className="chat-room-thread-header">
|
<div className="chat-room-thread-header">
|
||||||
@@ -2193,10 +2195,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className={threadClassName} style={threadKeyboardStyle}>
|
||||||
className={`chat-thread${keyboardOpen && hasKeyboardViewportDisplacement ? " chat-thread--keyboard-active" : ""}`}
|
|
||||||
style={threadKeyboardStyle}
|
|
||||||
>
|
|
||||||
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */}
|
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */}
|
||||||
{(hasThreadInView || !isMobile) && (
|
{(hasThreadInView || !isMobile) && (
|
||||||
<div className="chat-thread-header">
|
<div className="chat-thread-header">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { ChatView } from "../ChatView";
|
import { ChatView } from "../ChatView";
|
||||||
import * as useChatModule from "../../hooks/useChat";
|
import * as useChatModule from "../../hooks/useChat";
|
||||||
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||||
import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat";
|
import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat";
|
||||||
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||||
|
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||||
|
|
||||||
vi.mock("../../hooks/useChat");
|
vi.mock("../../hooks/useChat");
|
||||||
vi.mock("../../hooks/useChatRooms");
|
vi.mock("../../hooks/useChatRooms");
|
||||||
@@ -107,8 +108,32 @@ function mockMobileViewport() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockMobileVisualViewport({ innerHeight, vvHeight }: { innerHeight: number; vvHeight: number }) {
|
||||||
|
const resizeListeners = new Set<() => void>();
|
||||||
|
const scrollListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
const mockVV = {
|
||||||
|
height: vvHeight,
|
||||||
|
offsetTop: 0,
|
||||||
|
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||||
|
if (event === "resize") resizeListeners.add(cb);
|
||||||
|
if (event === "scroll") scrollListeners.add(cb);
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn((event: string, cb: () => void) => {
|
||||||
|
if (event === "resize") resizeListeners.delete(cb);
|
||||||
|
if (event === "scroll") scrollListeners.delete(cb);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, "innerHeight", { value: innerHeight, configurable: true, writable: true });
|
||||||
|
Object.defineProperty(window, "visualViewport", { value: mockVV, configurable: true, writable: true });
|
||||||
|
|
||||||
|
return { mockVV, listeners: { resize: resizeListeners, scroll: scrollListeners } };
|
||||||
|
}
|
||||||
|
|
||||||
describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
_resetInitialViewportHeight();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
if (!window.matchMedia) {
|
if (!window.matchMedia) {
|
||||||
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
|
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
|
||||||
@@ -206,6 +231,67 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
mediaSpy.mockRestore();
|
mediaSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies keyboard-active thread layout in room mode on mobile and preserves direct-chat parity", async () => {
|
||||||
|
const mediaSpy = mockMobileViewport();
|
||||||
|
const { listeners, mockVV } = mockMobileVisualViewport({ innerHeight: 800, vvHeight: 800 });
|
||||||
|
const originalVisualViewport = window.visualViewport;
|
||||||
|
const originalInnerHeight = window.innerHeight;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setup(
|
||||||
|
{
|
||||||
|
activeSession: activeSession,
|
||||||
|
messages: [{ id: "msg-1", sessionId: activeSession.id, role: "assistant", content: "Direct hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activeRoom: roomA,
|
||||||
|
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("focusin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "innerHeight", { value: 560, configurable: true, writable: true });
|
||||||
|
Object.defineProperty(mockVV, "height", { value: 560, configurable: true, writable: true });
|
||||||
|
act(() => {
|
||||||
|
for (const cb of listeners.resize) cb();
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomThread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(roomThread.classList.contains("chat-thread--keyboard-active")).toBe(true);
|
||||||
|
expect(roomThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
|
||||||
|
const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
directInput.focus();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("focusin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const directThread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(directThread.classList.contains("chat-thread--keyboard-active")).toBe(true);
|
||||||
|
expect(directThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "visualViewport", { value: originalVisualViewport, configurable: true, writable: true });
|
||||||
|
Object.defineProperty(window, "innerHeight", { value: originalInnerHeight, configurable: true, writable: true });
|
||||||
|
mediaSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps direct mode behavior unchanged when rooms are enabled", async () => {
|
it("keeps direct mode behavior unchanged when rooms are enabled", async () => {
|
||||||
localStorage.setItem("fusion:chat-scope", "direct");
|
localStorage.setItem("fusion:chat-scope", "direct");
|
||||||
const sendMessage = vi.fn();
|
const sendMessage = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user