feat(FN-3800): add session switcher to chat header and mobile switcher
This merge delivers five features: a session switcher for the chat header with mobile-aware dropdown styling and proper ARIA state, mailbox notification events with deep-link highlighting to the unread task, a fix for org chart connector endpoints in wide subtrees, a correction to merge finalize so Fusion-Task-Id: FN-3800
This commit is contained in:
@@ -366,6 +366,19 @@
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.mailbox-reply-context-static {
|
||||
margin: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mailbox-reply-context {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -1042,3 +1055,17 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
|
||||
.mailbox-message-highlight {
|
||||
animation: mailbox-flash 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes mailbox-flash {
|
||||
0% {
|
||||
background: color-mix(in srgb, var(--color-info) 20%, transparent);
|
||||
}
|
||||
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,21 @@ function messagePreview(content: string, max = 80): string {
|
||||
return `${content.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
function getDeepLinkedMessageId(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const paramId = params.get("mailbox-message");
|
||||
if (paramId) {
|
||||
return paramId;
|
||||
}
|
||||
|
||||
const hashMatch = /^#message-(.+)$/.exec(window.location.hash);
|
||||
return hashMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
||||
const allMessages = [...messages];
|
||||
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
||||
@@ -294,6 +309,58 @@ export function MailboxModal({
|
||||
}
|
||||
}, [projectId, activeTab]);
|
||||
|
||||
// Deep-link: open and highlight a specific message from URL params.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deepLinkedMessageId = getDeepLinkedMessageId();
|
||||
if (!deepLinkedMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = [
|
||||
...(inbox?.messages ?? []),
|
||||
...(outbox?.messages ?? []),
|
||||
...(agentMailbox?.inbox ?? []),
|
||||
...(agentMailbox?.outbox ?? []),
|
||||
...conversationMessages,
|
||||
].find((candidate) => candidate.id === deepLinkedMessageId);
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleOpenMessage(message);
|
||||
}, [isOpen, inbox, outbox, agentMailbox, conversationMessages, handleOpenMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deepLinkedMessageId = getDeepLinkedMessageId();
|
||||
if (!deepLinkedMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.getElementById(`message-${deepLinkedMessageId}`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
element.classList.add("mailbox-message-highlight");
|
||||
const timer = window.setTimeout(() => {
|
||||
element.classList.remove("mailbox-message-highlight");
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [isOpen, selectedMessage, conversationMessages]);
|
||||
|
||||
const handleCloseMessage = useCallback(() => {
|
||||
setSelectedMessage(null);
|
||||
setConversationMessages([]);
|
||||
@@ -587,7 +654,7 @@ export function MailboxModal({
|
||||
<div className="mailbox-content" data-testid="mailbox-content">
|
||||
{/* Message Detail View */}
|
||||
{selectedMessage && !showComposer && (
|
||||
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
|
||||
<div className="mailbox-message-detail" data-testid="mailbox-message-detail" id={`message-${selectedMessage.id}`}>
|
||||
<div className="mailbox-message-detail-header">
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
@@ -650,6 +717,7 @@ export function MailboxModal({
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={`message-${msg.id}`}
|
||||
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
||||
>
|
||||
<div className="mailbox-conversation-msg-header">
|
||||
@@ -725,6 +793,7 @@ export function MailboxModal({
|
||||
{inbox?.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={`message-${msg.id}`}
|
||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
@@ -760,6 +829,7 @@ export function MailboxModal({
|
||||
{outbox?.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={`message-${msg.id}`}
|
||||
className="mailbox-item"
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
@@ -864,6 +934,7 @@ export function MailboxModal({
|
||||
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={`message-${msg.id}`}
|
||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
@@ -887,6 +958,7 @@ export function MailboxModal({
|
||||
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={`message-${msg.id}`}
|
||||
className="mailbox-item"
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
|
||||
@@ -93,6 +93,29 @@ function messagePreview(content: string, max = 80): string {
|
||||
return `${content.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
function getDeepLinkedMessageId(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const paramId = params.get("mailbox-message");
|
||||
if (paramId) {
|
||||
return paramId;
|
||||
}
|
||||
|
||||
const hashMatch = /^#message-(.+)$/.exec(window.location.hash);
|
||||
return hashMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
function listMessageAnchorId(messageId: string): string {
|
||||
return `mailbox-list-message-${messageId}`;
|
||||
}
|
||||
|
||||
function detailMessageAnchorId(messageId: string): string {
|
||||
return `mailbox-detail-message-${messageId}`;
|
||||
}
|
||||
|
||||
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
||||
const allMessages = [...messages];
|
||||
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
||||
@@ -321,6 +344,50 @@ export function MailboxView({
|
||||
}
|
||||
}, [projectId, unreadCount, onUnreadCountChange, activeTab]);
|
||||
|
||||
// Deep-link: open and highlight a specific message from URL params.
|
||||
useEffect(() => {
|
||||
const deepLinkedMessageId = getDeepLinkedMessageId();
|
||||
if (!deepLinkedMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = [
|
||||
...(inbox?.messages ?? []),
|
||||
...(outbox?.messages ?? []),
|
||||
...(agentMailbox?.inbox ?? []),
|
||||
...(agentMailbox?.outbox ?? []),
|
||||
...conversationMessages,
|
||||
].find((candidate) => candidate.id === deepLinkedMessageId);
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleOpenMessage(message);
|
||||
}, [inbox, outbox, agentMailbox, conversationMessages, handleOpenMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const deepLinkedMessageId = getDeepLinkedMessageId();
|
||||
if (!deepLinkedMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.getElementById(detailMessageAnchorId(deepLinkedMessageId));
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
element.classList.add("mailbox-message-highlight");
|
||||
const timer = window.setTimeout(() => {
|
||||
element.classList.remove("mailbox-message-highlight");
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [selectedMessage, conversationMessages]);
|
||||
|
||||
const handleCloseMessage = useCallback(() => {
|
||||
setSelectedMessage(null);
|
||||
setConversationMessages([]);
|
||||
@@ -406,7 +473,7 @@ export function MailboxView({
|
||||
const threadMessages = buildReplyThread(conversationMessages, selectedMessage);
|
||||
|
||||
return (
|
||||
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
|
||||
<div className="mailbox-message-detail" data-testid="mailbox-message-detail" id={detailMessageAnchorId(selectedMessage.id)}>
|
||||
<div className="mailbox-message-detail-header">
|
||||
{isMobile && (
|
||||
<button
|
||||
@@ -470,6 +537,7 @@ export function MailboxView({
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={detailMessageAnchorId(msg.id)}
|
||||
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
||||
>
|
||||
<div className="mailbox-conversation-msg-header">
|
||||
@@ -477,7 +545,7 @@ export function MailboxView({
|
||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
</div>
|
||||
{replyToId && (
|
||||
<div className="mailbox-reply-context" data-testid={`mailbox-reply-context-${msg.id}`}>
|
||||
<div className="mailbox-reply-context-static" data-testid={`mailbox-reply-context-${msg.id}`}>
|
||||
↪ Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
|
||||
</div>
|
||||
)}
|
||||
@@ -493,7 +561,7 @@ export function MailboxView({
|
||||
{(threadMessages.length <= 1) && (
|
||||
<>
|
||||
{selectedMessage.metadata?.replyTo?.messageId && (
|
||||
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
||||
<div className="mailbox-reply-context-static" data-testid="mailbox-selected-reply-context">
|
||||
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
|
||||
</div>
|
||||
)}
|
||||
@@ -522,6 +590,7 @@ export function MailboxView({
|
||||
{inbox?.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={listMessageAnchorId(msg.id)}
|
||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
@@ -556,6 +625,7 @@ export function MailboxView({
|
||||
{outbox?.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={listMessageAnchorId(msg.id)}
|
||||
className="mailbox-item"
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
@@ -658,6 +728,7 @@ export function MailboxView({
|
||||
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={listMessageAnchorId(msg.id)}
|
||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
@@ -679,6 +750,7 @@ export function MailboxView({
|
||||
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
id={listMessageAnchorId(msg.id)}
|
||||
className="mailbox-item"
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
|
||||
@@ -246,6 +246,8 @@ const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
|
||||
"gridlock",
|
||||
"fallback-used",
|
||||
"memory-dreams-processed",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
];
|
||||
|
||||
const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [
|
||||
@@ -258,6 +260,8 @@ const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: s
|
||||
{ event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" },
|
||||
{ event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" },
|
||||
{ event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" },
|
||||
{ event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" },
|
||||
{ event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" },
|
||||
];
|
||||
|
||||
/** Well-known experimental feature flags with display labels.
|
||||
|
||||
@@ -1191,4 +1191,21 @@ describe("MailboxModal", () => {
|
||||
expect(lightContent).toContain("--star-active");
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights hash-linked message when modal opens", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
window.history.replaceState({}, "", "#message-msg-001");
|
||||
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
|
||||
// The deep-link opens the message detail view, so the element with id="message-msg-001"
|
||||
// is in the detail section, not the inbox list.
|
||||
const messageNode = await screen.findByTestId("mailbox-message-detail");
|
||||
expect(messageNode).toHaveAttribute("id", "message-msg-001");
|
||||
await waitFor(() => {
|
||||
expect(messageNode).toHaveClass("mailbox-message-highlight");
|
||||
});
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -734,7 +734,7 @@ describe("MailboxView", () => {
|
||||
expect(screen.getByTestId("mailbox-conversation")).toBeDefined();
|
||||
const replyContext = screen.getByTestId("mailbox-reply-context-msg-thread-reply");
|
||||
expect(replyContext).toBeDefined();
|
||||
expect(replyContext).toHaveClass("mailbox-reply-context");
|
||||
expect(replyContext).toHaveClass("mailbox-reply-context-static");
|
||||
expect(screen.getByText(/Replying to Can you share your current status\?/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -806,7 +806,7 @@ describe("MailboxView", () => {
|
||||
await waitFor(() => {
|
||||
const replyContext = screen.getByTestId("mailbox-selected-reply-context");
|
||||
expect(replyContext).toBeDefined();
|
||||
expect(replyContext).toHaveClass("mailbox-reply-context");
|
||||
expect(replyContext).toHaveClass("mailbox-reply-context-static");
|
||||
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("I have the answer now");
|
||||
});
|
||||
});
|
||||
@@ -1454,5 +1454,23 @@ describe("MailboxView", () => {
|
||||
const content = container.querySelector(".mailbox-content");
|
||||
expect(content).toBeTruthy();
|
||||
});
|
||||
|
||||
it("highlights deep-linked mailbox message from URL", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
window.history.replaceState({}, "", "?view=mailbox&mailbox-message=msg-001#message-msg-001");
|
||||
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
const messageNode = await screen.findByTestId("mailbox-message-detail");
|
||||
await waitFor(() => {
|
||||
expect(messageNode).toHaveAttribute("id", "mailbox-detail-message-msg-001");
|
||||
expect(messageNode).toHaveClass("mailbox-message-highlight");
|
||||
});
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2710,7 +2710,7 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByRole("button", { name: /Test notification/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows fallback-used and dreams events for both providers", async () => {
|
||||
it("shows fallback, dreams, and mailbox message events for both providers", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
@@ -2718,10 +2718,18 @@ describe("SettingsModal", () => {
|
||||
|
||||
expect(screen.getByLabelText("Fallback model used (recovered)")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("DREAMS.md entry added")).toBeInTheDocument();
|
||||
const agentToUserNtfy = screen.getByLabelText("Agent → user message") as HTMLInputElement;
|
||||
const agentToAgentNtfy = screen.getByLabelText("Agent → agent message") as HTMLInputElement;
|
||||
expect(agentToUserNtfy.checked).toBe(true);
|
||||
expect(agentToAgentNtfy.checked).toBe(true);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
||||
expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0);
|
||||
const [agentToUserWebhook] = screen.getAllByLabelText("Agent → user message") as HTMLInputElement[];
|
||||
const [agentToAgentWebhook] = screen.getAllByLabelText("Agent → agent message") as HTMLInputElement[];
|
||||
expect(agentToUserWebhook.checked).toBe(true);
|
||||
expect(agentToAgentWebhook.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("shows webhook fields when webhook provider is enabled", async () => {
|
||||
|
||||
@@ -125,6 +125,17 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||
setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id);
|
||||
}, [currentProject?.id, taskView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewParam = new URLSearchParams(window.location.search).get("view");
|
||||
if (viewParam && isTaskView(viewParam)) {
|
||||
setTaskView(normalizeTaskView(viewParam));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectsLoading || currentProjectLoading) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user