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:
Fusion
2026-05-11 16:19:12 -07:00
committed by gsxdsm
parent b918a2123d
commit d589069b21
5 changed files with 255 additions and 4 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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();