feat(FN-4042): add mobile chat quick switcher to thread header
Added a mobile chat quick switcher accessible from the thread header, with styling for the session dropdown and test coverage. Documentation for the feature was added to MOBILE.md and the dashboard guide. Fusion-Task-Id: FN-4042
This commit is contained in:
@@ -377,6 +377,101 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-mobile-session-menu {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-mobile-session-trigger {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-sm);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
min-height: calc(var(--space-lg) * 2);
|
||||
}
|
||||
|
||||
.chat-mobile-session-trigger .chat-thread-header-title,
|
||||
.chat-mobile-session-trigger .chat-model-tag {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-mobile-session-trigger svg:last-child {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-mobile-session-trigger:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.chat-mobile-session-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.chat-mobile-session-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-xs));
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 4;
|
||||
max-height: calc(var(--space-xl) * 10);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-mobile-session-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: calc(var(--space-lg) * 2);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.chat-mobile-session-option:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.chat-mobile-session-option:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.chat-mobile-session-option--active {
|
||||
background: color-mix(in srgb, var(--todo) 12%, transparent);
|
||||
}
|
||||
|
||||
.chat-mobile-session-option-title {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-thread-header-new-chat {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1343,7 +1438,11 @@
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.chat-mobile-session-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-thread-header-identity .chat-thread-header-title,
|
||||
|
||||
@@ -781,6 +781,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState<Record<string, CopyFeedbackState>>({});
|
||||
const [mobileSessionMenuOpen, setMobileSessionMenuOpen] = useState(false);
|
||||
|
||||
// File mention state and hook
|
||||
const [, setFileMentionPopupVisible] = useState(false);
|
||||
@@ -804,6 +805,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}, [fileMention.mentionActive]);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const mobileSessionMenuRef = useRef<HTMLDivElement>(null);
|
||||
const isUserScrollingRef = useRef(false);
|
||||
const lastAnchoredSessionStateRef = useRef<{ sessionId: string; loaded: boolean; hasMessages: boolean } | null>(null);
|
||||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||
@@ -1704,6 +1706,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const handleSessionClick = useCallback(
|
||||
(id: string) => {
|
||||
selectSession(id);
|
||||
setMobileSessionMenuOpen(false);
|
||||
if (isMobile) setSidebarVisible(false);
|
||||
},
|
||||
[selectSession, isMobile],
|
||||
@@ -1713,6 +1716,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const handleBack = useCallback(() => {
|
||||
selectSession("");
|
||||
setSidebarVisible(true);
|
||||
setMobileSessionMenuOpen(false);
|
||||
}, [selectSession]);
|
||||
|
||||
// Render empty state (no active session)
|
||||
@@ -1738,6 +1742,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
: activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat";
|
||||
|
||||
const showThreadHeaderModelTag = Boolean(activeModelTag && activeModelTag !== threadHeaderTitle);
|
||||
const showMobileSessionSwitcher = isMobile && chatScope === "direct" && !!activeSession;
|
||||
|
||||
const agentName =
|
||||
agentsMap.get(activeSession?.agentId ?? "")?.name ||
|
||||
@@ -1766,6 +1771,30 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
setShowAllAsPlain((value) => !value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileSessionMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (mobileSessionMenuRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setMobileSessionMenuOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
};
|
||||
}, [mobileSessionMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || chatScope !== "direct" || sidebarVisible) {
|
||||
setMobileSessionMenuOpen(false);
|
||||
}
|
||||
}, [isMobile, chatScope, sidebarVisible]);
|
||||
|
||||
const setCopyFeedback = useCallback((messageId: string, feedback: CopyFeedbackState) => {
|
||||
const existingTimeout = copyFeedbackTimeoutsRef.current.get(messageId);
|
||||
if (existingTimeout) {
|
||||
@@ -2245,9 +2274,45 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
</button>
|
||||
)}
|
||||
<div className="chat-thread-header-identity" data-testid="chat-thread-header-identity">
|
||||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
|
||||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
{showMobileSessionSwitcher ? (
|
||||
<div className="chat-mobile-session-menu" ref={mobileSessionMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon chat-mobile-session-trigger"
|
||||
data-testid="chat-mobile-session-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={mobileSessionMenuOpen}
|
||||
onClick={() => setMobileSessionMenuOpen((open) => !open)}
|
||||
>
|
||||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
|
||||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
{mobileSessionMenuOpen && (
|
||||
<div className="chat-mobile-session-dropdown" role="menu" data-testid="chat-mobile-session-dropdown">
|
||||
{filteredSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`chat-mobile-session-option${activeSession?.id === session.id ? " chat-mobile-session-option--active" : ""}`}
|
||||
data-testid={`chat-mobile-session-option-${session.id}`}
|
||||
onClick={() => handleSessionClick(session.id)}
|
||||
>
|
||||
<span className="chat-mobile-session-option-title">{session.title || "Untitled"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
|
||||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hasThreadInView && (
|
||||
<button
|
||||
|
||||
@@ -2484,6 +2484,24 @@ describe("Chat Session Delete Button CSS", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView CSS — mobile thread switcher", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
it("includes mobile session switcher trigger and dropdown tokenized contracts", () => {
|
||||
const triggerMatch = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/);
|
||||
const dropdownMatch = css.match(/\.chat-mobile-session-dropdown\s*\{([^}]*)\}/);
|
||||
expect(triggerMatch).toBeTruthy();
|
||||
expect(dropdownMatch).toBeTruthy();
|
||||
expect(triggerMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2)");
|
||||
expect(dropdownMatch?.[1]).toContain("background: var(--surface)");
|
||||
expect(dropdownMatch?.[1]).toContain("border: 1px solid var(--border)");
|
||||
});
|
||||
|
||||
it("keeps mobile override for header identity overflow visible so dropdown can render", () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*overflow:\s*visible;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView CSS — nested flexbox scrolling fix", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
@@ -3052,6 +3070,73 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: thread header title opens quick session switcher and closes after selection", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const selectSession = vi.fn();
|
||||
try {
|
||||
setupMockChat({
|
||||
sessions: [
|
||||
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
{ id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
|
||||
],
|
||||
filteredSessions: [
|
||||
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
{ id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
|
||||
],
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
selectSession,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
|
||||
expect(screen.getByTestId("chat-mobile-session-dropdown")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-mobile-session-option-session-002"));
|
||||
expect(selectSession).toHaveBeenCalledWith("session-002");
|
||||
expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: quick session switcher closes on outside click and is not shown for rooms", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
try {
|
||||
setupMockChat({ activeSession: activeSessionFixture });
|
||||
const initialRender = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
|
||||
expect(screen.getByTestId("chat-mobile-session-dropdown")).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
initialRender.unmount();
|
||||
|
||||
localStorage.setItem("fusion:chat-scope", "rooms");
|
||||
setupMockRooms({
|
||||
activeRoom: {
|
||||
id: "room-001",
|
||||
projectId: "proj-123",
|
||||
name: "backend",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
expect(screen.queryByTestId("chat-mobile-session-trigger")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("#backend")).toBeInTheDocument();
|
||||
} finally {
|
||||
localStorage.setItem("fusion:chat-scope", "direct");
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: send button sends on first touch and keeps composer focused", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user