From b663eebcb3dab00b3ccf87e43f02f6fcd369739d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 20:50:30 -0700 Subject: [PATCH] FN-7035: split oversized test suites Split oversized ChatView and notifier suites while updating the line-count baseline. - Move ChatView core contract and interaction coverage into focused sibling test files. - Move notifier runtime coverage into its own suite and share setup through a test harness. - Document the line-count guard decision and ratchet baseline entries for existing growth. Files changed: .../__tests__/ChatView.core-contracts.test.tsx | 623 ++++++++ .../__tests__/ChatView.core-interactions.test.tsx | 1261 +++++++++++++++ .../components/__tests__/ChatView.core.test.tsx | 1652 +------------------- .../engine/src/__tests__/notifier.runtime.test.ts | 810 ++++++++++ .../engine/src/__tests__/notifier.test-harness.ts | 71 + packages/engine/src/__tests__/notifier.test.ts | 847 +--------- scripts/check-file-line-count.mjs | 3 + scripts/line-count-baseline.json | 12 +- 8 files changed, 2778 insertions(+), 2501 deletions(-) Fusion-Task-Id: FN-7035 Fusion-Task-Lineage: 14cebb57-925b-41c7-9c8b-472f34b76fe2 --- .../ChatView.core-contracts.test.tsx | 623 +++++++ .../ChatView.core-interactions.test.tsx | 1261 +++++++++++++ .../__tests__/ChatView.core.test.tsx | 1652 +---------------- .../src/__tests__/notifier.runtime.test.ts | 810 ++++++++ .../src/__tests__/notifier.test-harness.ts | 71 + .../engine/src/__tests__/notifier.test.ts | 847 +-------- scripts/check-file-line-count.mjs | 3 + scripts/line-count-baseline.json | 12 +- 8 files changed, 2778 insertions(+), 2501 deletions(-) create mode 100644 packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx create mode 100644 packages/engine/src/__tests__/notifier.runtime.test.ts create mode 100644 packages/engine/src/__tests__/notifier.test-harness.ts diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx new file mode 100644 index 0000000000..68ad5133b6 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx @@ -0,0 +1,623 @@ +/* +FNXC:DashboardTests 2026-06-25-17:44: +ChatView suite split 5/5 (model/delete/css contracts) extracts model-tag, session-delete, and CSS-contract describes from ChatView.core.test.tsx so the cap-crosser is split into focused siblings rather than grandfathered. Shares ChatView.test-harness; vi.mock factories stay inline and self-contained per the harness TDZ warning. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { useState } from "react"; +import { ChatView } from "../ChatView"; +import type { DiscoveredSkill } from "@fusion/dashboard"; +import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat"; +import { loadAllAppCss } from "../../test/cssFixture"; +import { FileBrowserProvider } from "../../context/FileBrowserContext"; +import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache"; +import { + renderWithAct, + setupMockChat, + setupMockRooms, + mockViewportMode, + activeSessionFixture, + createMockSkill, + defaultChatState, + defaultModelsResponse, + mockUseChat, + mockFetchModels, + mockFetchDiscoveredSkills, + mockCreateObjectURL, + mockRevokeObjectURL, + mockClipboardWriteText, + installChatViewEnv, +} from "./ChatView.test-harness"; + +// Mock the hooks +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +// Mock lucide-react icons - spread actual module and override specific icons +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MessageSquare: ({ "data-testid": testId, ...props }: any) => ( + + ), + Send: ({ "data-testid": testId, ...props }: any) => , + Plus: ({ "data-testid": testId, ...props }: any) => , + Search: ({ "data-testid": testId, ...props }: any) => , + Trash2: ({ "data-testid": testId, ...props }: any) => , + Archive: ({ "data-testid": testId, ...props }: any) => , + Pencil: ({ "data-testid": testId, ...props }: any) => , + ChevronLeft: ({ "data-testid": testId, ...props }: any) => , + Bot: ({ "data-testid": testId, ...props }: any) => , + Square: ({ "data-testid": testId, ...props }: any) => , + Eye: ({ "data-testid": testId, ...props }: any) => , + EyeOff: ({ "data-testid": testId, ...props }: any) => , + Paperclip: ({ "data-testid": testId, ...props }: any) => , + File: ({ "data-testid": testId, ...props }: any) => , + Copy: ({ "data-testid": testId, ...props }: any) => , + Check: ({ "data-testid": testId, ...props }: any) => , + }; +}); + +// Mock CustomModelDropdown - no longer used but kept for other tests +vi.mock("../CustomModelDropdown", () => ({ + CustomModelDropdown: ({ + value, + onChange, + label, + }: { + value: string; + onChange: (value: string) => void; + label: string; + }) => ( + + ), +})); + +// Mock fetchAgents for new chat dialog +vi.mock("../../api", () => ({ + fetchModels: vi.fn().mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, + ], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }), + 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: {} }, + { id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} }, + ]), + fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), +})); + +installChatViewEnv(); + + +describe("formatModelTag helper function", () => { + // Import the function for testing - we'll test it via the UI behavior instead + // The function is not exported, so we test it indirectly through the component + + it("formats claude-sonnet-4-5 model ID correctly", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Test", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag?.textContent).toContain("Claude Sonnet"); + }); + + it("formats gpt-4o model ID correctly", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Test", + modelProvider: "openai", + modelId: "gpt-4o", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag?.textContent).toContain("GPT-4o"); + }); + + it("formats gemini-2.5-pro model ID correctly", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Test", + modelProvider: "google", + modelId: "gemini-2.5-pro", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag?.textContent).toContain("Gemini"); + }); + + it("returns null when modelId is missing", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test", + modelProvider: "anthropic", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag).not.toBeInTheDocument(); + }); + + it("returns null when provider is missing", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag).not.toBeInTheDocument(); + }); +}); + +describe("Chat Session Delete Button", () => { + it("renders delete button on each session item", async () => { + setupMockChat({ + sessions: [ + { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + { id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + ], + filteredSessions: [ + { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + { id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const deleteButtons = screen.getAllByTestId("chat-session-delete-btn"); + expect(deleteButtons.length).toBe(2); + }); + + it("clicking delete button shows confirmation dialog", async () => { + 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" }], + 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" }], + }); + + await renderWithAct(); + + const deleteButton = screen.getByTestId("chat-session-delete-btn"); + await userEvent.click(deleteButton); + + // Dialog should be open + const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; + expect(dialog).toBeInTheDocument(); + expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); + }); + + it("clicking delete button does not select the session", async () => { + const selectSession = vi.fn(); + 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" }], + 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" }], + selectSession, + }); + + await renderWithAct(); + + const deleteButton = screen.getByTestId("chat-session-delete-btn"); + await userEvent.click(deleteButton); + + expect(selectSession).not.toHaveBeenCalled(); + }); + + it("renames from the desktop context menu with the current title prefilled", async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Renamed Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + 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" }, + 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" }], + 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" }], + renameSession, + }); + + const view = await renderWithAct(); + + fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); + expect(screen.getByTestId("chat-context-rename")).toBeInTheDocument(); + await userEvent.click(screen.getByTestId("chat-context-rename")); + + const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; + expect(input.value).toBe("Test Chat"); + await userEvent.clear(input); + await userEvent.type(input, "Renamed Chat"); + await userEvent.click(screen.getByTestId("chat-rename-save")); + + expect(renameSession).toHaveBeenCalledWith("session-001", "Renamed Chat"); + + setupMockChat({ + activeSession: renamedSession, + sessions: [renamedSession], + filteredSessions: [renamedSession], + renameSession, + }); + await act(async () => { + view.rerender(); + }); + + expect(screen.getByTestId("chat-session-session-001")).toHaveTextContent("Renamed Chat"); + const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(headerTitle).toHaveTextContent("Renamed Chat"); + }); + + it("prefills rename as empty for an untitled session and names it", async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const untitledSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: null, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + activeSession: untitledSession, + sessions: [untitledSession], + filteredSessions: [untitledSession], + renameSession, + }); + + await renderWithAct(); + + fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); + await userEvent.click(screen.getByTestId("chat-context-rename")); + + const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; + expect(input.value).toBe(""); + await userEvent.type(input, "Named from Untitled"); + await userEvent.click(screen.getByTestId("chat-rename-save")); + + expect(renameSession).toHaveBeenCalledWith("session-001", "Named from Untitled"); + }); + + it("renames from the mobile session switcher and preserves the active header title surface", async () => { + const restoreMatchMedia = mockViewportMode("mobile"); + const renameSession = vi.fn().mockResolvedValue(undefined); + try { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + renameSession, + }); + + const view = await renderWithAct(); + + expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Chat"); + await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); + await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-001")); + + const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; + expect(input.value).toBe("Mobile Chat"); + await userEvent.clear(input); + await userEvent.type(input, "Mobile Renamed"); + await userEvent.click(screen.getByTestId("chat-rename-save")); + + expect(renameSession).toHaveBeenCalledWith("session-001", "Mobile Renamed"); + + const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Renamed", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + activeSession: renamedSession, + sessions: [renamedSession], + filteredSessions: [renamedSession], + renameSession, + }); + await act(async () => { + view.rerender(); + }); + + expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Renamed"); + const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(headerTitle).toHaveTextContent("Mobile Renamed"); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + + it("confirming delete calls deleteSession", async () => { + const deleteSession = vi.fn(); + 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" }], + 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" }], + deleteSession, + }); + + await renderWithAct(); + + const deleteButton = screen.getByTestId("chat-session-delete-btn"); + await userEvent.click(deleteButton); + + // Click confirm in dialog + const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; + await userEvent.click(within(dialog!).getByText("Delete")); + + expect(deleteSession).toHaveBeenCalledWith("session-001"); + }); +}); + +describe("ChatView CSS — failure bubble contracts", () => { + const css = loadAllAppCss(); + + it("uses shared error surface tokens for failure bubbles and detail affordances", async () => { + const bubbleMatch = css.match(/\.chat-message--failure\s*\{([^}]*)\}/); + const badgeMatch = css.match(/\.chat-message-failure-badge\s*\{([^}]*)\}/); + const detailsMatch = css.match(/\.chat-message-failure-details\s*\{([^}]*)\}/); + const linkMatch = css.match(/\.chat-message-failure-reference-link\s*\{([^}]*)\}/); + + expect(bubbleMatch?.[1]).toContain("background: var(--status-error-bg)"); + expect(bubbleMatch?.[1]).toContain("border: var(--btn-border-width) solid var(--status-error-bg-deep)"); + expect(badgeMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); + expect(detailsMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); + expect(linkMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); + }); +}); + +describe("ChatView CSS — tablet assistant bubble width", () => { + const css = loadAllAppCss(); + + it("widens assistant, streaming, and failure bubbles on tablet containers while preserving user and mobile caps", async () => { + const baseMessageRule = css.match(/\.chat-message\s*\{([^}]*)\}/); + const userRule = css.match(/\.chat-message--user\s*\{([^}]*)\}/); + const tabletRule = css.match( + /@container\s+chat-view\s+\(min-width:\s*48\.0625rem\)\s+and\s+\(max-width:\s*64rem\)\s*\{([\s\S]*?)\n\}/, + ); + + expect(baseMessageRule?.[1]).toContain("max-width: 75%"); + expect(userRule?.[1]).toContain("align-self: flex-end"); + expect(userRule?.[1]).not.toContain("max-width"); + expect(tabletRule?.[1]).toMatch( + /\.chat-message--assistant,\s*\.chat-message--streaming,\s*\.chat-message--failure\s*\{[^}]*max-width:\s*88%/, + ); + expect(tabletRule?.[1]).not.toMatch(/\.chat-message--user\s*\{[^}]*max-width/); + expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*90%/); + }); +}); + +describe("ChatView CSS — active state edge highlights", () => { + const css = loadAllAppCss(); + + function findRule(selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); + expect(match).toBeTruthy(); + return match?.[1] ?? ""; + } + + function mobileRuleContains(selector: string, propertyPattern: RegExp): boolean { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const mobileRegex = /@media[^{}]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; + let match; + while ((match = mobileRegex.exec(css)) !== null) { + const ruleMatch = match[1].match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); + if (ruleMatch && propertyPattern.test(ruleMatch[1])) { + return true; + } + } + return false; + } + + it("keeps scope-tab active tint without the removed bottom underline", async () => { + const activeScopeRule = findRule(".chat-sidebar-scope-btn--active"); + + expect(activeScopeRule).toContain("background: var(--card)"); + expect(activeScopeRule).toContain("color: var(--text)"); + expect(activeScopeRule).not.toContain("box-shadow"); + expect(activeScopeRule).not.toContain("inset"); + }); + + it("renders the header Direct/Rooms toggle with visible borders", async () => { + const headerScopeRule = findRule(".chat-view-header-scope-toggle"); + const headerScopeButtonRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn"); + const headerActiveScopeRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn--active"); + + expect(headerScopeRule).toContain("border: 1px solid var(--border)"); + expect(headerScopeRule).toContain("height: var(--view-header-content-row, 28px)"); + expect(headerScopeButtonRule).toContain("border: 1px solid transparent"); + expect(headerScopeButtonRule).toContain("height: 100%"); + expect(headerActiveScopeRule).toContain("border-color: var(--todo)"); + }); + + it("collapses header Direct/Rooms labels to icons at very narrow widths", async () => { + expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px/); + expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip:\s*rect\(0 0 0 0\)/); + }); + + it("keeps active chat-row background without the removed left edge or offset", async () => { + const activeSessionRule = findRule(".chat-session-item--active"); + + expect(activeSessionRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent)"); + expect(activeSessionRule).not.toContain("border-left"); + expect(activeSessionRule).not.toContain("padding-left: calc(var(--space-md) - (var(--btn-border-width) * 3))"); + }); + + it("does not reintroduce either removed highlight in mobile rules", async () => { + expect(mobileRuleContains(".chat-sidebar-scope-btn--active", /box-shadow\s*:\s*inset/)).toBe(false); + expect(mobileRuleContains(".chat-session-item--active", /border-left\s*:/)).toBe(false); + expect(mobileRuleContains(".chat-session-item--active", /padding-left\s*:\s*calc\(var\(--space-md\)\s*-\s*\(var\(--btn-border-width\)\s*\*\s*3\)\)/)).toBe(false); + }); +}); + +describe("FN-3911 chat session list layout", () => { + const css = loadAllAppCss(); + + it("reserves right padding on title and preview rows so text clears the delete button", async () => { + const titleMatch = css.match(/\.chat-session-title\s*\{([^}]*)\}/); + const previewMatch = css.match(/\.chat-session-preview\s*\{([^}]*)\}/); + expect(titleMatch).toBeTruthy(); + expect(previewMatch).toBeTruthy(); + expect(titleMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); + expect(previewMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); + }); + + it("FN-4385: keeps mobile title/preview clearance matched to compact delete button", async () => { + expect(css).toMatch( + /@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-session-title,\s*\.chat-session-preview\s*\{\s*padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\);\s*\}/, + ); + }); +}); + +describe("Chat Session Delete Button CSS", () => { + const css = loadAllAppCss(); + + it(".chat-session-delete-btn exists with opacity: 0", async () => { + const match = css.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("opacity: 0"); + }); + + it(".chat-session-item:hover .chat-session-delete-btn has opacity: 1", async () => { + const match = css.match(/\.chat-session-item:hover\s*\.chat-session-delete-btn\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("opacity: 1"); + }); + + it("FN-4352: mobile delete button stays visible without min-size inflation", async () => { + const mobileRegex = /@media[^{]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; + let match; + let deleteRule = ""; + while ((match = mobileRegex.exec(css)) !== null) { + const mediaContent = match[1]; + if (mediaContent.includes(".chat-session-delete-btn")) { + deleteRule = mediaContent.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/)?.[1] ?? ""; + if (deleteRule) break; + } + } + + expect(deleteRule).toContain("opacity: 1"); + expect(deleteRule).not.toContain("min-width:"); + expect(deleteRule).not.toContain("min-height:"); + }); +}); + +describe("ChatView CSS — mobile thread switcher", () => { + const css = loadAllAppCss(); + + it("includes mobile session switcher trigger and dropdown tokenized contracts", async () => { + const triggerMatch = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/); + const triggerIconMatch = css.match(/\.chat-mobile-session-trigger\s*>\s*svg\s*\{([^}]*)\}/); + const dropdownMatch = css.match(/\.chat-mobile-session-dropdown\s*\{([^}]*)\}/); + const optionMatch = css.match(/\.chat-mobile-session-option\s*\{([^}]*)\}/); + const optionTitleMatch = css.match(/\.chat-mobile-session-option-title\s*\{([^}]*)\}/); + expect(triggerMatch).toBeTruthy(); + expect(triggerIconMatch).toBeTruthy(); + expect(dropdownMatch).toBeTruthy(); + expect(optionMatch).toBeTruthy(); + expect(optionTitleMatch).toBeTruthy(); + expect(triggerMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs))"); + expect(triggerMatch?.[1]).toContain("min-width: 0"); + expect(triggerMatch?.[1]).toContain("padding: var(--space-xs) var(--space-sm)"); + expect(triggerMatch?.[1]).toContain("font: inherit"); + expect(triggerMatch?.[1]).toContain("line-height: normal"); + expect(triggerMatch?.[1]).toContain("text-align: left"); + expect(triggerIconMatch?.[1]).toContain("width: var(--icon-size-md)"); + expect(triggerIconMatch?.[1]).toContain("height: var(--icon-size-md)"); + expect(dropdownMatch?.[1]).toContain("background: var(--surface)"); + expect(dropdownMatch?.[1]).toContain("border: 1px solid var(--border)"); + expect(optionMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2.25)"); + expect(optionMatch?.[1]).toContain("align-items: flex-start"); + expect(optionMatch?.[1]).toContain("line-height: normal"); + expect(optionTitleMatch?.[1]).toContain("display: block"); + expect(optionTitleMatch?.[1]).toContain("line-height: normal"); + expect(optionTitleMatch?.[1]).toContain("white-space: normal"); + expect(optionTitleMatch?.[1]).toContain("overflow-wrap: anywhere"); + }); + + it("keeps mobile override for header identity overflow visible so dropdown can render", async () => { + 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(); + + it(".chat-session-list has min-height: 0 for proper vertical scrolling", async () => { + const match = css.match(/\.chat-session-list\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("min-height: 0"); + }); + + it(".chat-thread has min-height: 0 for proper vertical scrolling", async () => { + const match = css.match(/\.chat-thread\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("min-height: 0"); + }); + + it(".chat-messages has min-height: 0 for proper vertical scrolling", async () => { + const match = css.match(/\.chat-messages\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("min-height: 0"); + }); +}); + diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx new file mode 100644 index 0000000000..5098f69e4f --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx @@ -0,0 +1,1261 @@ +/* +FNXC:DashboardTests 2026-06-25-17:44: +ChatView suite split 4/5 (core interactions) extracts the attachments, mentions, slash-skill, and streaming-state blocks from ChatView.core.test.tsx so each focused sibling stays under the line-count guard without dropping coverage. Shares ChatView.test-harness; vi.mock factories stay inline and self-contained per the harness TDZ warning. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { useState } from "react"; +import { ChatView } from "../ChatView"; +import type { DiscoveredSkill } from "@fusion/dashboard"; +import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat"; +import { loadAllAppCss } from "../../test/cssFixture"; +import { FileBrowserProvider } from "../../context/FileBrowserContext"; +import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache"; +import { + renderWithAct, + setupMockChat, + setupMockRooms, + mockViewportMode, + activeSessionFixture, + createMockSkill, + defaultChatState, + defaultModelsResponse, + mockUseChat, + mockFetchModels, + mockFetchDiscoveredSkills, + mockCreateObjectURL, + mockRevokeObjectURL, + mockClipboardWriteText, + installChatViewEnv, +} from "./ChatView.test-harness"; + +// Mock the hooks +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +// Mock lucide-react icons - spread actual module and override specific icons +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MessageSquare: ({ "data-testid": testId, ...props }: any) => ( + + ), + Send: ({ "data-testid": testId, ...props }: any) => , + Plus: ({ "data-testid": testId, ...props }: any) => , + Search: ({ "data-testid": testId, ...props }: any) => , + Trash2: ({ "data-testid": testId, ...props }: any) => , + Archive: ({ "data-testid": testId, ...props }: any) => , + Pencil: ({ "data-testid": testId, ...props }: any) => , + ChevronLeft: ({ "data-testid": testId, ...props }: any) => , + Bot: ({ "data-testid": testId, ...props }: any) => , + Square: ({ "data-testid": testId, ...props }: any) => , + Eye: ({ "data-testid": testId, ...props }: any) => , + EyeOff: ({ "data-testid": testId, ...props }: any) => , + Paperclip: ({ "data-testid": testId, ...props }: any) => , + File: ({ "data-testid": testId, ...props }: any) => , + Copy: ({ "data-testid": testId, ...props }: any) => , + Check: ({ "data-testid": testId, ...props }: any) => , + }; +}); + +// Mock CustomModelDropdown - no longer used but kept for other tests +vi.mock("../CustomModelDropdown", () => ({ + CustomModelDropdown: ({ + value, + onChange, + label, + }: { + value: string; + onChange: (value: string) => void; + label: string; + }) => ( + + ), +})); + +// Mock fetchAgents for new chat dialog +vi.mock("../../api", () => ({ + fetchModels: vi.fn().mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, + ], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }), + 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: {} }, + { id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} }, + ]), + fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), +})); + +installChatViewEnv(); + + +describe("ChatView core interactions", () => { + describe("attachments", () => { + it("clicking paperclip triggers hidden file input", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, "click"); + + await userEvent.click(screen.getByTestId("chat-attach-btn")); + expect(clickSpy).toHaveBeenCalled(); + }); + + it("allows attaching an image and sends with attachments only", async () => { + const sendMessage = vi.fn(); + setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage }); + await renderWithAct(); + + const attachButton = screen.getByTestId("chat-attach-btn"); + expect(attachButton).toBeInTheDocument(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const imageFile = new File(["image"], "shot.png", { type: "image/png" }); + fireEvent.change(fileInput, { target: { files: [imageFile] } }); + + expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); + const sendButton = screen.getByTestId("chat-send-btn"); + expect(sendButton).not.toBeDisabled(); + + await userEvent.click(sendButton); + expect(sendMessage).toHaveBeenCalledWith("", [imageFile]); + expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); + }); + + it("accepts non-image files and renders filename preview", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const textFile = new File(["hello"], "note.txt", { type: "text/plain" }); + fireEvent.change(fileInput, { target: { files: [textFile] } }); + + expect(await screen.findByText("note.txt")).toBeInTheDocument(); + expect(mockCreateObjectURL).not.toHaveBeenCalled(); + }); + + it("adds image attachments from paste events", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + const imageFile = new File(["image"], "paste.png", { type: "image/png" }); + fireEvent.paste(textarea, { clipboardData: { files: [imageFile] } }); + + expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); + }); + + it("adds attachments from drag-and-drop", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const wrapper = document.querySelector(".chat-input-wrapper") as HTMLElement; + const textFile = new File(["log"], "drop.log", { type: "text/x-log" }); + fireEvent.drop(wrapper, { dataTransfer: { files: [textFile] } }); + + expect(await screen.findByText("drop.log")).toBeInTheDocument(); + }); + + it("removes pending attachments and revokes preview urls", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const imageFile = new File(["image"], "shot.png", { type: "image/png" }); + fireEvent.change(fileInput, { target: { files: [imageFile] } }); + + const removeButton = await screen.findByTestId("chat-attachment-remove-0"); + await userEvent.click(removeButton); + + expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:shot.png"); + expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); + }); + + it("renders message attachments inline as actionable links", async () => { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [ + { + id: "msg-attach", + sessionId: "session-001", + role: "assistant", + content: "Attached files", + createdAt: "2026-04-08T00:00:00.000Z", + attachments: [ + { + id: "att-1", + filename: "img-1.png", + originalName: "capture.png", + mimeType: "image/png", + size: 10, + createdAt: "2026-04-08T00:00:00.000Z", + }, + { + id: "att-2", + filename: "note.txt", + originalName: "note.txt", + mimeType: "text/plain", + size: 20, + createdAt: "2026-04-08T00:00:00.000Z", + }, + ], + }, + ], + }); + + await renderWithAct(); + + const links = screen.getAllByTestId("chat-message-attachment"); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/img-1.png"); + expect(links[0]).toHaveAttribute("target", "_blank"); + expect(links[1]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/note.txt"); + expect(screen.getByText("note.txt")).toBeInTheDocument(); + }); + }); + + describe("agent mentions", () => { + it("shows mention popup when @ is typed", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "@"); + + expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument(); + }); + + it("filters mention popup by text after @", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "@be"); + + expect(await screen.findByTestId("agent-mention-item-agent-002")).toBeInTheDocument(); + expect(screen.queryByTestId("agent-mention-item-agent-001")).not.toBeInTheDocument(); + }); + + it("hides mention popup on Escape", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "@"); + expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument(); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument(); + }); + + it("inserts mention text when selecting an agent", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + await userEvent.type(textarea, "@al"); + + const mentionItem = await screen.findByTestId("agent-mention-item-agent-001"); + await userEvent.click(mentionItem); + + expect(textarea.value).toBe("@Alpha "); + expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument(); + }); + + it("uses room member ordering in popup and marks non-member mention chips in room messages", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + setupMockRooms({ + activeRoom: { + id: "room-001", + slug: "engineering", + name: "engineering", + createdBy: "agent-001", + status: "active", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }, + activeRoomMembers: [ + { roomId: "room-001", agentId: "agent-001", role: "member", addedAt: "2026-04-08T00:00:00.000Z" }, + ], + messages: [ + { + id: "room-msg-1", + roomId: "room-001", + role: "user", + content: "Ping @Alpha and @Beta", + senderAgentId: "agent-001", + metadata: null, + attachments: [], + mentions: ["agent-001", "agent-002"], + createdAt: "2026-04-08T00:00:00.000Z", + }, + ], + }); + + const allCss = await loadAllAppCss(); + const style = document.createElement("style"); + style.textContent = allCss; + document.head.appendChild(style); + + await renderWithAct(); + + const user = userEvent.setup({ delay: null }); + await user.click(screen.getByTestId("chat-sidebar-scope-rooms")); + const textarea = screen.getByTestId("chat-input"); + await user.type(textarea, "@"); + + expect(screen.getByTestId("agent-mention-members-header")).toBeInTheDocument(); + expect(screen.queryByTestId("agent-mention-others-header")).not.toBeInTheDocument(); + + const bubble = screen.getByText("Ping", { exact: false }).closest(".chat-message--user"); + expect(bubble).toBeTruthy(); + + const memberChip = screen.getByText("@Alpha", { selector: ".chat-mention-chip" }); + const nonMemberChip = screen.getByText("@Beta", { selector: ".chat-mention-chip--non-member" }); + expect(nonMemberChip).toHaveAttribute("title", "Not a member of engineering"); + + // FN-4520: member mention chip text must not visually collapse into sent-bubble background. + expect(getComputedStyle(memberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor); + // FN-4520: non-member mention chip text must remain legible inside sent bubbles. + expect(getComputedStyle(nonMemberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor); + }); + + it("renders assistant mentions as plain text in markdown mode", async () => { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [ + { + id: "msg-001", + sessionId: "session-001", + role: "assistant", + content: "Talk to @Alpha and @Unknown next.", + createdAt: "2026-04-08T00:00:00.000Z", + }, + ], + }); + + await renderWithAct(); + + await waitFor(() => { + expect(screen.getByText(/Talk to @Alpha and @Unknown next\./)).toBeInTheDocument(); + }); + expect(screen.queryByText("@Alpha", { selector: ".chat-mention-chip" })).toBeNull(); + expect(screen.queryByText("@Unknown", { selector: ".chat-mention-chip" })).toBeNull(); + }); + }); + + describe("slash skill autocomplete", () => { + it("shows the skill menu when typing slash in the chat input", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-refactor", name: "refactor/code", relativePath: "skills/refactor/code.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + + expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); + expect(screen.getByText("refactor/code")).toBeInTheDocument(); + }); + + it("filters discovered skills from slash input", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), + createMockSkill({ id: "skill-deploy", name: "deploy/app", relativePath: "skills/deploy/app.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/re"); + + expect(await screen.findByText("review/pr")).toBeInTheDocument(); + expect(screen.queryByText("deploy/app")).not.toBeInTheDocument(); + }); + + it("inserts /skill command when clicking a menu item", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/re"); + + await userEvent.click(await screen.findByRole("option", { name: /review\/pr/i })); + + expect(textarea).toHaveValue("/skill:review/pr "); + expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); + }); + + it("supports arrow navigation with wrapping and Enter selection", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), + createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), + createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + fireEvent.change(textarea, { target: { value: "/" } }); + await screen.findByRole("option", { name: /alpha/i }); + + // Wrap to bottom from the first item. + fireEvent.keyDown(textarea, { key: "ArrowUp" }); + await waitFor(() => + expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ), + ); + + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(textarea).toHaveValue("/skill:gamma ")); + }); + + it("keeps the keyboard highlight when revalidation re-delivers an identical skill list", async () => { + // Regression: the SWR skills cache re-delivers content-identical lists + // with fresh array identities (cache reads re-parse; revalidation + // notifies a new array). The highlight reset must key on skill ids, not + // array identity, or a revalidation landing mid-navigation wipes the + // user's keyboard position (the source of this test family's CI flakes). + const skillsList = [ + createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), + createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), + createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }), + ]; + // Seed the cache so the menu renders before the (deferred) revalidation fetch. + writeCache(`${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}proj-123`, skillsList); + let resolveFetch!: (skills: DiscoveredSkill[]) => void; + mockFetchDiscoveredSkills.mockImplementationOnce( + () => new Promise((resolve) => { resolveFetch = resolve; }), + ); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + fireEvent.change(textarea, { target: { value: "/" } }); + await screen.findByRole("option", { name: /alpha/i }); + + fireEvent.keyDown(textarea, { key: "ArrowUp" }); + await waitFor(() => + expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ), + ); + + // Revalidation lands mid-navigation: identical content, new identity. + await act(async () => { + resolveFetch(JSON.parse(JSON.stringify(skillsList)) as DiscoveredSkill[]); + }); + + expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ); + }); + + it("supports selecting highlighted skill with Tab", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), + createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + await screen.findByRole("option", { name: /alpha/i }); + + await userEvent.keyboard("{ArrowDown}"); + expect(screen.getByRole("option", { name: /beta/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ); + + await userEvent.keyboard("{Tab}"); + expect(textarea).toHaveValue("/skill:beta "); + }); + + it("closes the menu when pressing Escape", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); + }); + + it("closes the menu when slash trigger pattern no longer matches", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/re"); + expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); + + await userEvent.type(textarea, " "); + expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); + }); + + it("shows loading indicator while discovered skills are still loading", async () => { + let resolveSkills: ((skills: DiscoveredSkill[]) => void) | undefined; + mockFetchDiscoveredSkills.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSkills = resolve; + }), + ); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + + expect(await screen.findByText("Loading skills…")).toBeInTheDocument(); + + resolveSkills?.([createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" })]); + await waitFor(() => { + expect(screen.getByText("review/pr")).toBeInTheDocument(); + }); + }); + + it("does not crash when discovered skills fail to load", async () => { + mockFetchDiscoveredSkills.mockRejectedValueOnce(new Error("skills endpoint unavailable")); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + + expect(await screen.findByText("No skills available")).toBeInTheDocument(); + }); + }); + + it("disables send button when input is empty", async () => { + setupMockChat({ + 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" }, + messages: [], + }); + + await renderWithAct(); + + const sendButton = screen.getByTestId("chat-send-btn"); + expect(sendButton).toBeDisabled(); + }); + + it("renders stop button when streaming", async () => { + setupMockChat({ + 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" }, + messages: [], + isStreaming: true, + }); + + await renderWithAct(); + + expect(screen.getByTestId("chat-stop-btn")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-send-btn")).not.toBeInTheDocument(); + }); + + it("clicking stop button calls stopStreaming", async () => { + const stopStreaming = vi.fn(); + setupMockChat({ + 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" }, + messages: [], + isStreaming: true, + stopStreaming, + }); + + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-stop-btn")); + expect(stopStreaming).toHaveBeenCalledTimes(1); + }); + + it("FN-6576 does not let a send gesture trailing click press the swapped stop button", async () => { + const viewportSpy = mockViewportMode("mobile"); + const sendMessage = vi.fn(); + const stopStreaming = vi.fn(); + mockUseChat.mockImplementation(() => { + const [isStreaming, setIsStreaming] = useState(false); + return { + ...defaultChatState, + activeSession: activeSessionFixture, + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + messages: [], + isStreaming, + sendMessage: (message, files) => { + sendMessage(message, files); + setIsStreaming(true); + }, + stopStreaming, + } satisfies UseChatReturn; + }); + + await renderWithAct(); + + fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start streaming" } }); + await act(async () => { + fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" }); + fireEvent.touchStart(screen.getByTestId("chat-send-btn")); + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith("Start streaming", []); + + await act(async () => { + fireEvent.click(screen.getByTestId("chat-stop-btn")); + }); + expect(stopStreaming).not.toHaveBeenCalled(); + viewportSpy.mockRestore(); + }); + + it("FN-6576 allows a standalone mobile stop tap exactly once", async () => { + const viewportSpy = mockViewportMode("mobile"); + const stopStreaming = vi.fn(); + setupMockChat({ + 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" }, + messages: [], + isStreaming: true, + stopStreaming, + }); + + await renderWithAct(); + + await act(async () => { + fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" }); + fireEvent.touchStart(screen.getByTestId("chat-stop-btn")); + fireEvent.click(screen.getByTestId("chat-stop-btn")); + }); + expect(stopStreaming).toHaveBeenCalledTimes(1); + viewportSpy.mockRestore(); + }); + + it("FN-6576 allows a genuine stop tap within the send click-latch window", async () => { + const viewportSpy = mockViewportMode("mobile"); + const sendMessage = vi.fn(); + const stopStreaming = vi.fn(); + mockUseChat.mockImplementation(() => { + const [isStreaming, setIsStreaming] = useState(false); + return { + ...defaultChatState, + activeSession: activeSessionFixture, + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + messages: [], + isStreaming, + sendMessage: (message, files) => { + sendMessage(message, files); + setIsStreaming(true); + }, + stopStreaming, + } satisfies UseChatReturn; + }); + + await renderWithAct(); + + fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start then stop" } }); + await act(async () => { + fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" }); + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + + await act(async () => { + fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" }); + fireEvent.touchStart(screen.getByTestId("chat-stop-btn")); + fireEvent.click(screen.getByTestId("chat-stop-btn")); + }); + expect(stopStreaming).toHaveBeenCalledTimes(1); + viewportSpy.mockRestore(); + }); + + it("renders send button when not streaming", async () => { + setupMockChat({ + 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" }, + messages: [], + isStreaming: false, + }); + + await renderWithAct(); + + expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument(); + }); + + it("renders pending message indicator and dismisses it", async () => { + const clearPendingMessage = vi.fn(); + setupMockChat({ + 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" }, + messages: [], + pendingMessage: "Queued while streaming", + clearPendingMessage, + }); + + await renderWithAct(); + + expect(screen.getByTestId("chat-pending-indicator")).toHaveTextContent("Queued: Queued while streaming"); + + await userEvent.click(screen.getByTestId("chat-pending-dismiss")); + expect(clearPendingMessage).toHaveBeenCalledTimes(1); + }); + + it("textarea is enabled during streaming", async () => { + setupMockChat({ + 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" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "Thinking...", + }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + expect(textarea).not.toBeDisabled(); + }); + + it("user can type while streaming", async () => { + setupMockChat({ + 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" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "Thinking...", + }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + + // User should be able to type in the textarea while streaming + fireEvent.change(textarea, { target: { value: "Second message" } }); + expect((textarea as HTMLTextAreaElement).value).toBe("Second message"); + }); + + it("shows streaming indicator when isStreaming is true", async () => { + setupMockChat({ + 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" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "Typing...", + }); + + await renderWithAct(); + + // Streaming message should show + const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; + expect(streamingMessage).toBeInTheDocument(); + expect(streamingMessage?.textContent).toContain("Typing"); + }); + + it("shows thinking blocks collapsed by default", async () => { + setupMockChat({ + 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" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Here's my response", thinkingOutput: "I need to think about this...", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const message = screen.getByTestId("chat-message-msg-001"); + const details = message.querySelector("details"); + expect(details).toBeInTheDocument(); + expect(details).toHaveProperty("open", false); + }); + + describe("streaming states", () => { + it("keeps mobile thread visible when active session metadata refreshes during streaming", async () => { + const mediaQuerySpy = mockViewportMode("mobile"); + const streamingState: UseChatReturn = { + ...defaultChatState, + sessions: [{ ...activeSessionFixture }], + filteredSessions: [{ ...activeSessionFixture }], + activeSession: { ...activeSessionFixture }, + messages: [], + isStreaming: true, + streamingText: "", + streamingThinking: "", + }; + const refreshedStreamingState: UseChatReturn = { + ...streamingState, + sessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], + filteredSessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], + activeSession: null, + }; + + mockUseChat + .mockReturnValueOnce(streamingState) + .mockReturnValue(refreshedStreamingState); + + const { rerender } = await renderWithAct(); + + expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working"); + rerender(); + + expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working"); + expect(screen.queryByText("Start a new conversation")).not.toBeInTheDocument(); + expect(screen.queryByText("No messages yet. Start the conversation!")).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); + + void mediaQuerySpy; + }); + + it("keeps the streaming indicator visible while message history is still loading", async () => { + setupMockChat({ + 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" }, + messages: [], + messagesLoading: true, + isStreaming: true, + streamingText: "", + streamingThinking: "", + }); + + await renderWithAct(); + + const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; + expect(streamingMessage).toBeInTheDocument(); + expect(streamingMessage?.textContent).toContain("Working"); + expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument(); + }); + + it("shows waiting indicator when streaming starts before text arrives", async () => { + setupMockChat({ + 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" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "", + streamingThinking: "", + }); + + await renderWithAct(); + + // Streaming message should show with "Working..." text + const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; + expect(streamingMessage).toBeInTheDocument(); + expect(streamingMessage?.textContent).toContain("Working"); + + // Waiting class should be present + const waitingContent = streamingMessage?.querySelector(".chat-message-content--waiting"); + expect(waitingContent).toBeInTheDocument(); + + // Typing indicator dots should be rendered + const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator"); + expect(typingIndicator).toBeInTheDocument(); + expect(typingIndicator?.querySelectorAll("span").length).toBe(3); + }); + + it("shows thinking indicator when streaming thinking arrives before text", async () => { + setupMockChat({ + 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" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "", + streamingThinking: "analyzing the request...", + }); + + await renderWithAct(); + + // Streaming message should show with "Thinking..." text + const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; + expect(streamingMessage).toBeInTheDocument(); + expect(streamingMessage?.textContent).toContain("Thinking"); + + // Thinking details should be rendered + const thinkingDetails = streamingMessage?.querySelector("details.chat-message-thinking"); + expect(thinkingDetails).toBeInTheDocument(); + expect(thinkingDetails?.querySelector(".chat-message-thinking-content")?.textContent).toContain("analyzing the request"); + + // Typing indicator dots should be rendered + const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator"); + expect(typingIndicator).toBeInTheDocument(); + }); + }); + + it("filters sessions by search query", async () => { + setupMockChat({ + sessions: [ + { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + { id: "session-002", agentId: "agent-002", status: "active", title: "Backend API", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" }, + ], + filteredSessions: [ + { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + ], + searchQuery: "frontend", + setSearchQuery: vi.fn(), + }); + + await renderWithAct(); + + expect(screen.getByText("Frontend work")).toBeInTheDocument(); + expect(screen.queryByText("Backend API")).not.toBeInTheDocument(); + }); + + it("shows empty state with Start Chat button (no inline agent selector)", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + expect(screen.getByText("Start a new conversation")).toBeInTheDocument(); + // Find the New Chat button in the empty state section + const emptyStateText = screen.getByText("Start a new conversation"); + const emptyState = emptyStateText.closest(".chat-empty-state") as HTMLElement | null; + expect(within(emptyState!).getByRole("button", { name: /new chat/i })).toBeInTheDocument(); + // Should NOT have an agent selector in empty state + expect(emptyState?.querySelector("select")).toBeNull(); + }); + + it("shows context menu on right-click", async () => { + 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" }], + 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" }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + + await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); + + expect(screen.getByTestId("chat-context-archive")).toBeInTheDocument(); + expect(screen.getByTestId("chat-context-delete")).toBeInTheDocument(); + }); + + it("calls archiveSession when clicking Archive in context menu", async () => { + const archiveSession = vi.fn(); + 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" }], + 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" }], + archiveSession, + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); + + await userEvent.click(screen.getByTestId("chat-context-archive")); + + expect(archiveSession).toHaveBeenCalledWith("session-001"); + }); + + it("shows delete confirmation dialog", async () => { + 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" }], + 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" }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); + + await userEvent.click(screen.getByTestId("chat-context-delete")); + + // Dialog should be open + const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; + expect(dialog).toBeInTheDocument(); + expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); + }); + + it("shows formatted model label for fn agent sessions in sidebar", async () => { + setupMockChat({ + sessions: [{ + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "My Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + updatedAt: "2026-04-08T00:00:00.000Z", + createdAt: "2026-04-08T00:00:00.000Z", + }], + filteredSessions: [{ + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "My Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + updatedAt: "2026-04-08T00:00:00.000Z", + createdAt: "2026-04-08T00:00:00.000Z", + }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + expect(within(sessionItem).getByText("Claude Sonnet 4.5")).toBeInTheDocument(); + expect(within(sessionItem).queryByText("Fusion")).not.toBeInTheDocument(); + }); + + it("shows Fusion fallback for fn agent sessions in sidebar without model info", async () => { + mockFetchModels.mockResolvedValue({ + models: [], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: null, + defaultModelId: null, + }); + setupMockChat({ + sessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + expect(within(sessionItem).getByText("Fusion")).toBeInTheDocument(); + }); + + it("shows agent ID for non-fn agent sessions in sidebar", async () => { + setupMockChat({ + sessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + // Should show the agent ID (truncated to 30 chars) + expect(within(sessionItem).getByText("my-custom-agent")).toBeInTheDocument(); + }); + + it("shows formatted model name in thread header title for fn agent sessions", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(title).toBeInTheDocument(); + expect(title).toHaveTextContent("Claude Sonnet 4.5"); + expect(title).not.toHaveTextContent("Fusion"); + }); + + it("shows model tag in thread header when non-fn session has model", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Agent Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + { id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, + ], + }); + + await renderWithAct(); + + const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null; + expect(headerModelTag).toBeInTheDocument(); + expect(headerModelTag?.textContent).toContain("Claude"); + }); + + it("does not show duplicate model tag in thread header for fn agent sessions", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(title).toHaveTextContent("Claude Sonnet 4.5"); + + const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null; + expect(headerModelTag).toBeNull(); + }); + + it("keeps provider identity text grouped in header while render toggle stays on the same row", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Agent Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const header = document.querySelector(".chat-thread-header") as HTMLElement | null; + const identity = screen.getByTestId("chat-thread-header-identity"); + const toggle = screen.getByTestId("chat-thread-render-toggle"); + const providerIcon = identity.querySelector(".provider-icon"); + const modelTag = identity.querySelector(".chat-model-tag"); + const newChatButton = screen.getByTestId("chat-new-btn"); + + expect(header).toBeInTheDocument(); + expect(newChatButton.closest(".view-header")).toBeInTheDocument(); + expect(providerIcon).toBeInTheDocument(); + expect(within(identity).getByText("Agent Chat")).toBeInTheDocument(); + expect(modelTag).toBeInTheDocument(); + expect(modelTag).toHaveTextContent("Claude Sonnet 4.5"); + expect(toggle).toBeInTheDocument(); + expect(header?.children[header.children.length - 1]).toBe(toggle); + expect(document.querySelectorAll(".chat-thread-header .chat-model-tag")).toHaveLength(1); + }); + + it("does not show model tag when session has no model", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test Chat", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + { id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag).not.toBeInTheDocument(); + }); + + it("does not repeat the model tag in per-message avatars for non-fn sessions", async () => { + // Per-message model tags were intentionally removed — the model is shown + // once in the thread header. The avatar should still render with the + // agent name (no agent identity collapse for real agents) but no model + // tag inside it. + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Agent Chat", + modelProvider: "openai", + modelId: "gpt-4o", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, + ], + }); + + await renderWithAct(); + + const messageBubble = screen.getByTestId("chat-message-msg-001"); + const avatar = messageBubble.querySelector(".chat-message-avatar") as HTMLElement | null; + expect(avatar).toBeInTheDocument(); + expect(avatar?.querySelector(".chat-model-tag")).toBeNull(); + }); + + it("hides per-message identity entirely for fn agent (model-only) sessions even when model is set", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test Chat", + modelProvider: "openai", + modelId: "gpt-4o", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, + ], + }); + + await renderWithAct(); + + const messageBubble = screen.getByTestId("chat-message-msg-001"); + expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx index 5821ef9c48..16c81a2901 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx @@ -1,6 +1,6 @@ /* FNXC:DashboardTests 2026-06-25-16:30: -ChatView suite split 1/3 (core) (was ChatView.test.tsx). Shares ChatView.test-harness for fixtures, +ChatView suite split 1/5 (core) (was ChatView.test.tsx). Shares ChatView.test-harness for fixtures, helpers, vi.mocked handles, and installChatViewEnv(). vi.mock factories stay inline & self -contained here (see harness header for why delegating them triggers a TDZ ReferenceError). */ @@ -1569,1653 +1569,5 @@ describe("ChatView", () => { expect(sendMessage).not.toHaveBeenCalled(); }); - describe("attachments", () => { - it("clicking paperclip triggers hidden file input", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - const clickSpy = vi.spyOn(fileInput, "click"); - - await userEvent.click(screen.getByTestId("chat-attach-btn")); - expect(clickSpy).toHaveBeenCalled(); - }); - - it("allows attaching an image and sends with attachments only", async () => { - const sendMessage = vi.fn(); - setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage }); - await renderWithAct(); - - const attachButton = screen.getByTestId("chat-attach-btn"); - expect(attachButton).toBeInTheDocument(); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - const imageFile = new File(["image"], "shot.png", { type: "image/png" }); - fireEvent.change(fileInput, { target: { files: [imageFile] } }); - - expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); - const sendButton = screen.getByTestId("chat-send-btn"); - expect(sendButton).not.toBeDisabled(); - - await userEvent.click(sendButton); - expect(sendMessage).toHaveBeenCalledWith("", [imageFile]); - expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); - }); - - it("accepts non-image files and renders filename preview", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - const textFile = new File(["hello"], "note.txt", { type: "text/plain" }); - fireEvent.change(fileInput, { target: { files: [textFile] } }); - - expect(await screen.findByText("note.txt")).toBeInTheDocument(); - expect(mockCreateObjectURL).not.toHaveBeenCalled(); - }); - - it("adds image attachments from paste events", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - const imageFile = new File(["image"], "paste.png", { type: "image/png" }); - fireEvent.paste(textarea, { clipboardData: { files: [imageFile] } }); - - expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); - }); - - it("adds attachments from drag-and-drop", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const wrapper = document.querySelector(".chat-input-wrapper") as HTMLElement; - const textFile = new File(["log"], "drop.log", { type: "text/x-log" }); - fireEvent.drop(wrapper, { dataTransfer: { files: [textFile] } }); - - expect(await screen.findByText("drop.log")).toBeInTheDocument(); - }); - - it("removes pending attachments and revokes preview urls", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - const imageFile = new File(["image"], "shot.png", { type: "image/png" }); - fireEvent.change(fileInput, { target: { files: [imageFile] } }); - - const removeButton = await screen.findByTestId("chat-attachment-remove-0"); - await userEvent.click(removeButton); - - expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:shot.png"); - expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); - }); - - it("renders message attachments inline as actionable links", async () => { - setupMockChat({ - activeSession: activeSessionFixture, - messages: [ - { - id: "msg-attach", - sessionId: "session-001", - role: "assistant", - content: "Attached files", - createdAt: "2026-04-08T00:00:00.000Z", - attachments: [ - { - id: "att-1", - filename: "img-1.png", - originalName: "capture.png", - mimeType: "image/png", - size: 10, - createdAt: "2026-04-08T00:00:00.000Z", - }, - { - id: "att-2", - filename: "note.txt", - originalName: "note.txt", - mimeType: "text/plain", - size: 20, - createdAt: "2026-04-08T00:00:00.000Z", - }, - ], - }, - ], - }); - - await renderWithAct(); - - const links = screen.getAllByTestId("chat-message-attachment"); - expect(links).toHaveLength(2); - expect(links[0]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/img-1.png"); - expect(links[0]).toHaveAttribute("target", "_blank"); - expect(links[1]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/note.txt"); - expect(screen.getByText("note.txt")).toBeInTheDocument(); - }); - }); - - describe("agent mentions", () => { - it("shows mention popup when @ is typed", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "@"); - - expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument(); - }); - - it("filters mention popup by text after @", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "@be"); - - expect(await screen.findByTestId("agent-mention-item-agent-002")).toBeInTheDocument(); - expect(screen.queryByTestId("agent-mention-item-agent-001")).not.toBeInTheDocument(); - }); - - it("hides mention popup on Escape", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "@"); - expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument(); - - await userEvent.keyboard("{Escape}"); - expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument(); - }); - - it("inserts mention text when selecting an agent", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; - await userEvent.type(textarea, "@al"); - - const mentionItem = await screen.findByTestId("agent-mention-item-agent-001"); - await userEvent.click(mentionItem); - - expect(textarea.value).toBe("@Alpha "); - expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument(); - }); - - it("uses room member ordering in popup and marks non-member mention chips in room messages", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - setupMockRooms({ - activeRoom: { - id: "room-001", - slug: "engineering", - name: "engineering", - createdBy: "agent-001", - status: "active", - createdAt: "2026-04-08T00:00:00.000Z", - updatedAt: "2026-04-08T00:00:00.000Z", - }, - activeRoomMembers: [ - { roomId: "room-001", agentId: "agent-001", role: "member", addedAt: "2026-04-08T00:00:00.000Z" }, - ], - messages: [ - { - id: "room-msg-1", - roomId: "room-001", - role: "user", - content: "Ping @Alpha and @Beta", - senderAgentId: "agent-001", - metadata: null, - attachments: [], - mentions: ["agent-001", "agent-002"], - createdAt: "2026-04-08T00:00:00.000Z", - }, - ], - }); - - const allCss = await loadAllAppCss(); - const style = document.createElement("style"); - style.textContent = allCss; - document.head.appendChild(style); - - await renderWithAct(); - - const user = userEvent.setup({ delay: null }); - await user.click(screen.getByTestId("chat-sidebar-scope-rooms")); - const textarea = screen.getByTestId("chat-input"); - await user.type(textarea, "@"); - - expect(screen.getByTestId("agent-mention-members-header")).toBeInTheDocument(); - expect(screen.queryByTestId("agent-mention-others-header")).not.toBeInTheDocument(); - - const bubble = screen.getByText("Ping", { exact: false }).closest(".chat-message--user"); - expect(bubble).toBeTruthy(); - - const memberChip = screen.getByText("@Alpha", { selector: ".chat-mention-chip" }); - const nonMemberChip = screen.getByText("@Beta", { selector: ".chat-mention-chip--non-member" }); - expect(nonMemberChip).toHaveAttribute("title", "Not a member of engineering"); - - // FN-4520: member mention chip text must not visually collapse into sent-bubble background. - expect(getComputedStyle(memberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor); - // FN-4520: non-member mention chip text must remain legible inside sent bubbles. - expect(getComputedStyle(nonMemberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor); - }); - - it("renders assistant mentions as plain text in markdown mode", async () => { - setupMockChat({ - activeSession: activeSessionFixture, - messages: [ - { - id: "msg-001", - sessionId: "session-001", - role: "assistant", - content: "Talk to @Alpha and @Unknown next.", - createdAt: "2026-04-08T00:00:00.000Z", - }, - ], - }); - - await renderWithAct(); - - await waitFor(() => { - expect(screen.getByText(/Talk to @Alpha and @Unknown next\./)).toBeInTheDocument(); - }); - expect(screen.queryByText("@Alpha", { selector: ".chat-mention-chip" })).toBeNull(); - expect(screen.queryByText("@Unknown", { selector: ".chat-mention-chip" })).toBeNull(); - }); - }); - - describe("slash skill autocomplete", () => { - it("shows the skill menu when typing slash in the chat input", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-refactor", name: "refactor/code", relativePath: "skills/refactor/code.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - - expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); - expect(screen.getByText("refactor/code")).toBeInTheDocument(); - }); - - it("filters discovered skills from slash input", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), - createMockSkill({ id: "skill-deploy", name: "deploy/app", relativePath: "skills/deploy/app.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/re"); - - expect(await screen.findByText("review/pr")).toBeInTheDocument(); - expect(screen.queryByText("deploy/app")).not.toBeInTheDocument(); - }); - - it("inserts /skill command when clicking a menu item", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/re"); - - await userEvent.click(await screen.findByRole("option", { name: /review\/pr/i })); - - expect(textarea).toHaveValue("/skill:review/pr "); - expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); - }); - - it("supports arrow navigation with wrapping and Enter selection", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), - createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), - createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - fireEvent.change(textarea, { target: { value: "/" } }); - await screen.findByRole("option", { name: /alpha/i }); - - // Wrap to bottom from the first item. - fireEvent.keyDown(textarea, { key: "ArrowUp" }); - await waitFor(() => - expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( - "chat-skill-menu-item--highlighted", - ), - ); - - fireEvent.keyDown(textarea, { key: "Enter" }); - await waitFor(() => expect(textarea).toHaveValue("/skill:gamma ")); - }); - - it("keeps the keyboard highlight when revalidation re-delivers an identical skill list", async () => { - // Regression: the SWR skills cache re-delivers content-identical lists - // with fresh array identities (cache reads re-parse; revalidation - // notifies a new array). The highlight reset must key on skill ids, not - // array identity, or a revalidation landing mid-navigation wipes the - // user's keyboard position (the source of this test family's CI flakes). - const skillsList = [ - createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), - createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), - createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }), - ]; - // Seed the cache so the menu renders before the (deferred) revalidation fetch. - writeCache(`${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}proj-123`, skillsList); - let resolveFetch!: (skills: DiscoveredSkill[]) => void; - mockFetchDiscoveredSkills.mockImplementationOnce( - () => new Promise((resolve) => { resolveFetch = resolve; }), - ); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - fireEvent.change(textarea, { target: { value: "/" } }); - await screen.findByRole("option", { name: /alpha/i }); - - fireEvent.keyDown(textarea, { key: "ArrowUp" }); - await waitFor(() => - expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( - "chat-skill-menu-item--highlighted", - ), - ); - - // Revalidation lands mid-navigation: identical content, new identity. - await act(async () => { - resolveFetch(JSON.parse(JSON.stringify(skillsList)) as DiscoveredSkill[]); - }); - - expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( - "chat-skill-menu-item--highlighted", - ); - }); - - it("supports selecting highlighted skill with Tab", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), - createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - await screen.findByRole("option", { name: /alpha/i }); - - await userEvent.keyboard("{ArrowDown}"); - expect(screen.getByRole("option", { name: /beta/i })).toHaveClass( - "chat-skill-menu-item--highlighted", - ); - - await userEvent.keyboard("{Tab}"); - expect(textarea).toHaveValue("/skill:beta "); - }); - - it("closes the menu when pressing Escape", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); - - await userEvent.keyboard("{Escape}"); - expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); - }); - - it("closes the menu when slash trigger pattern no longer matches", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/re"); - expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); - - await userEvent.type(textarea, " "); - expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); - }); - - it("shows loading indicator while discovered skills are still loading", async () => { - let resolveSkills: ((skills: DiscoveredSkill[]) => void) | undefined; - mockFetchDiscoveredSkills.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveSkills = resolve; - }), - ); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - - expect(await screen.findByText("Loading skills…")).toBeInTheDocument(); - - resolveSkills?.([createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" })]); - await waitFor(() => { - expect(screen.getByText("review/pr")).toBeInTheDocument(); - }); - }); - - it("does not crash when discovered skills fail to load", async () => { - mockFetchDiscoveredSkills.mockRejectedValueOnce(new Error("skills endpoint unavailable")); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - - expect(await screen.findByText("No skills available")).toBeInTheDocument(); - }); - }); - - it("disables send button when input is empty", async () => { - setupMockChat({ - 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" }, - messages: [], - }); - - await renderWithAct(); - - const sendButton = screen.getByTestId("chat-send-btn"); - expect(sendButton).toBeDisabled(); - }); - - it("renders stop button when streaming", async () => { - setupMockChat({ - 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" }, - messages: [], - isStreaming: true, - }); - - await renderWithAct(); - - expect(screen.getByTestId("chat-stop-btn")).toBeInTheDocument(); - expect(screen.queryByTestId("chat-send-btn")).not.toBeInTheDocument(); - }); - - it("clicking stop button calls stopStreaming", async () => { - const stopStreaming = vi.fn(); - setupMockChat({ - 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" }, - messages: [], - isStreaming: true, - stopStreaming, - }); - - await renderWithAct(); - - await userEvent.click(screen.getByTestId("chat-stop-btn")); - expect(stopStreaming).toHaveBeenCalledTimes(1); - }); - - it("FN-6576 does not let a send gesture trailing click press the swapped stop button", async () => { - const viewportSpy = mockViewportMode("mobile"); - const sendMessage = vi.fn(); - const stopStreaming = vi.fn(); - mockUseChat.mockImplementation(() => { - const [isStreaming, setIsStreaming] = useState(false); - return { - ...defaultChatState, - activeSession: activeSessionFixture, - sessions: [activeSessionFixture], - filteredSessions: [activeSessionFixture], - messages: [], - isStreaming, - sendMessage: (message, files) => { - sendMessage(message, files); - setIsStreaming(true); - }, - stopStreaming, - } satisfies UseChatReturn; - }); - - await renderWithAct(); - - fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start streaming" } }); - await act(async () => { - fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" }); - fireEvent.touchStart(screen.getByTestId("chat-send-btn")); - }); - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(sendMessage).toHaveBeenCalledWith("Start streaming", []); - - await act(async () => { - fireEvent.click(screen.getByTestId("chat-stop-btn")); - }); - expect(stopStreaming).not.toHaveBeenCalled(); - viewportSpy.mockRestore(); - }); - - it("FN-6576 allows a standalone mobile stop tap exactly once", async () => { - const viewportSpy = mockViewportMode("mobile"); - const stopStreaming = vi.fn(); - setupMockChat({ - 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" }, - messages: [], - isStreaming: true, - stopStreaming, - }); - - await renderWithAct(); - - await act(async () => { - fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" }); - fireEvent.touchStart(screen.getByTestId("chat-stop-btn")); - fireEvent.click(screen.getByTestId("chat-stop-btn")); - }); - expect(stopStreaming).toHaveBeenCalledTimes(1); - viewportSpy.mockRestore(); - }); - - it("FN-6576 allows a genuine stop tap within the send click-latch window", async () => { - const viewportSpy = mockViewportMode("mobile"); - const sendMessage = vi.fn(); - const stopStreaming = vi.fn(); - mockUseChat.mockImplementation(() => { - const [isStreaming, setIsStreaming] = useState(false); - return { - ...defaultChatState, - activeSession: activeSessionFixture, - sessions: [activeSessionFixture], - filteredSessions: [activeSessionFixture], - messages: [], - isStreaming, - sendMessage: (message, files) => { - sendMessage(message, files); - setIsStreaming(true); - }, - stopStreaming, - } satisfies UseChatReturn; - }); - - await renderWithAct(); - - fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start then stop" } }); - await act(async () => { - fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" }); - }); - expect(sendMessage).toHaveBeenCalledTimes(1); - await act(async () => { - await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); - - await act(async () => { - fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" }); - fireEvent.touchStart(screen.getByTestId("chat-stop-btn")); - fireEvent.click(screen.getByTestId("chat-stop-btn")); - }); - expect(stopStreaming).toHaveBeenCalledTimes(1); - viewportSpy.mockRestore(); - }); - - it("renders send button when not streaming", async () => { - setupMockChat({ - 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" }, - messages: [], - isStreaming: false, - }); - - await renderWithAct(); - - expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument(); - }); - - it("renders pending message indicator and dismisses it", async () => { - const clearPendingMessage = vi.fn(); - setupMockChat({ - 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" }, - messages: [], - pendingMessage: "Queued while streaming", - clearPendingMessage, - }); - - await renderWithAct(); - - expect(screen.getByTestId("chat-pending-indicator")).toHaveTextContent("Queued: Queued while streaming"); - - await userEvent.click(screen.getByTestId("chat-pending-dismiss")); - expect(clearPendingMessage).toHaveBeenCalledTimes(1); - }); - - it("textarea is enabled during streaming", async () => { - setupMockChat({ - 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" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "Thinking...", - }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - expect(textarea).not.toBeDisabled(); - }); - - it("user can type while streaming", async () => { - setupMockChat({ - 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" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "Thinking...", - }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - - // User should be able to type in the textarea while streaming - fireEvent.change(textarea, { target: { value: "Second message" } }); - expect((textarea as HTMLTextAreaElement).value).toBe("Second message"); - }); - - it("shows streaming indicator when isStreaming is true", async () => { - setupMockChat({ - 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" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "Typing...", - }); - - await renderWithAct(); - - // Streaming message should show - const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; - expect(streamingMessage).toBeInTheDocument(); - expect(streamingMessage?.textContent).toContain("Typing"); - }); - - it("shows thinking blocks collapsed by default", async () => { - setupMockChat({ - 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" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Here's my response", thinkingOutput: "I need to think about this...", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const message = screen.getByTestId("chat-message-msg-001"); - const details = message.querySelector("details"); - expect(details).toBeInTheDocument(); - expect(details).toHaveProperty("open", false); - }); - - describe("streaming states", () => { - it("keeps mobile thread visible when active session metadata refreshes during streaming", async () => { - const mediaQuerySpy = mockViewportMode("mobile"); - const streamingState: UseChatReturn = { - ...defaultChatState, - sessions: [{ ...activeSessionFixture }], - filteredSessions: [{ ...activeSessionFixture }], - activeSession: { ...activeSessionFixture }, - messages: [], - isStreaming: true, - streamingText: "", - streamingThinking: "", - }; - const refreshedStreamingState: UseChatReturn = { - ...streamingState, - sessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], - filteredSessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], - activeSession: null, - }; - - mockUseChat - .mockReturnValueOnce(streamingState) - .mockReturnValue(refreshedStreamingState); - - const { rerender } = await renderWithAct(); - - expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working"); - rerender(); - - expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working"); - expect(screen.queryByText("Start a new conversation")).not.toBeInTheDocument(); - expect(screen.queryByText("No messages yet. Start the conversation!")).not.toBeInTheDocument(); - expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); - - void mediaQuerySpy; - }); - - it("keeps the streaming indicator visible while message history is still loading", async () => { - setupMockChat({ - 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" }, - messages: [], - messagesLoading: true, - isStreaming: true, - streamingText: "", - streamingThinking: "", - }); - - await renderWithAct(); - - const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; - expect(streamingMessage).toBeInTheDocument(); - expect(streamingMessage?.textContent).toContain("Working"); - expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument(); - }); - - it("shows waiting indicator when streaming starts before text arrives", async () => { - setupMockChat({ - 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" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "", - streamingThinking: "", - }); - - await renderWithAct(); - - // Streaming message should show with "Working..." text - const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; - expect(streamingMessage).toBeInTheDocument(); - expect(streamingMessage?.textContent).toContain("Working"); - - // Waiting class should be present - const waitingContent = streamingMessage?.querySelector(".chat-message-content--waiting"); - expect(waitingContent).toBeInTheDocument(); - - // Typing indicator dots should be rendered - const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator"); - expect(typingIndicator).toBeInTheDocument(); - expect(typingIndicator?.querySelectorAll("span").length).toBe(3); - }); - - it("shows thinking indicator when streaming thinking arrives before text", async () => { - setupMockChat({ - 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" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "", - streamingThinking: "analyzing the request...", - }); - - await renderWithAct(); - - // Streaming message should show with "Thinking..." text - const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; - expect(streamingMessage).toBeInTheDocument(); - expect(streamingMessage?.textContent).toContain("Thinking"); - - // Thinking details should be rendered - const thinkingDetails = streamingMessage?.querySelector("details.chat-message-thinking"); - expect(thinkingDetails).toBeInTheDocument(); - expect(thinkingDetails?.querySelector(".chat-message-thinking-content")?.textContent).toContain("analyzing the request"); - - // Typing indicator dots should be rendered - const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator"); - expect(typingIndicator).toBeInTheDocument(); - }); - }); - - it("filters sessions by search query", async () => { - setupMockChat({ - sessions: [ - { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - { id: "session-002", agentId: "agent-002", status: "active", title: "Backend API", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" }, - ], - filteredSessions: [ - { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - ], - searchQuery: "frontend", - setSearchQuery: vi.fn(), - }); - - await renderWithAct(); - - expect(screen.getByText("Frontend work")).toBeInTheDocument(); - expect(screen.queryByText("Backend API")).not.toBeInTheDocument(); - }); - - it("shows empty state with Start Chat button (no inline agent selector)", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - expect(screen.getByText("Start a new conversation")).toBeInTheDocument(); - // Find the New Chat button in the empty state section - const emptyStateText = screen.getByText("Start a new conversation"); - const emptyState = emptyStateText.closest(".chat-empty-state") as HTMLElement | null; - expect(within(emptyState!).getByRole("button", { name: /new chat/i })).toBeInTheDocument(); - // Should NOT have an agent selector in empty state - expect(emptyState?.querySelector("select")).toBeNull(); - }); - - it("shows context menu on right-click", async () => { - 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" }], - 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" }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - - await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); - - expect(screen.getByTestId("chat-context-archive")).toBeInTheDocument(); - expect(screen.getByTestId("chat-context-delete")).toBeInTheDocument(); - }); - - it("calls archiveSession when clicking Archive in context menu", async () => { - const archiveSession = vi.fn(); - 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" }], - 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" }], - archiveSession, - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); - - await userEvent.click(screen.getByTestId("chat-context-archive")); - - expect(archiveSession).toHaveBeenCalledWith("session-001"); - }); - - it("shows delete confirmation dialog", async () => { - 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" }], - 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" }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); - - await userEvent.click(screen.getByTestId("chat-context-delete")); - - // Dialog should be open - const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; - expect(dialog).toBeInTheDocument(); - expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); - }); - - it("shows formatted model label for fn agent sessions in sidebar", async () => { - setupMockChat({ - sessions: [{ - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "My Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - updatedAt: "2026-04-08T00:00:00.000Z", - createdAt: "2026-04-08T00:00:00.000Z", - }], - filteredSessions: [{ - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "My Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - updatedAt: "2026-04-08T00:00:00.000Z", - createdAt: "2026-04-08T00:00:00.000Z", - }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - expect(within(sessionItem).getByText("Claude Sonnet 4.5")).toBeInTheDocument(); - expect(within(sessionItem).queryByText("Fusion")).not.toBeInTheDocument(); - }); - - it("shows Fusion fallback for fn agent sessions in sidebar without model info", async () => { - mockFetchModels.mockResolvedValue({ - models: [], - favoriteProviders: [], - favoriteModels: [], - defaultProvider: null, - defaultModelId: null, - }); - setupMockChat({ - sessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - expect(within(sessionItem).getByText("Fusion")).toBeInTheDocument(); - }); - - it("shows agent ID for non-fn agent sessions in sidebar", async () => { - setupMockChat({ - sessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - // Should show the agent ID (truncated to 30 chars) - expect(within(sessionItem).getByText("my-custom-agent")).toBeInTheDocument(); - }); - - it("shows formatted model name in thread header title for fn agent sessions", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null; - expect(title).toBeInTheDocument(); - expect(title).toHaveTextContent("Claude Sonnet 4.5"); - expect(title).not.toHaveTextContent("Fusion"); - }); - - it("shows model tag in thread header when non-fn session has model", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Agent Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - { id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, - ], - }); - - await renderWithAct(); - - const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null; - expect(headerModelTag).toBeInTheDocument(); - expect(headerModelTag?.textContent).toContain("Claude"); - }); - - it("does not show duplicate model tag in thread header for fn agent sessions", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null; - expect(title).toHaveTextContent("Claude Sonnet 4.5"); - - const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null; - expect(headerModelTag).toBeNull(); - }); - - it("keeps provider identity text grouped in header while render toggle stays on the same row", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Agent Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const header = document.querySelector(".chat-thread-header") as HTMLElement | null; - const identity = screen.getByTestId("chat-thread-header-identity"); - const toggle = screen.getByTestId("chat-thread-render-toggle"); - const providerIcon = identity.querySelector(".provider-icon"); - const modelTag = identity.querySelector(".chat-model-tag"); - const newChatButton = screen.getByTestId("chat-new-btn"); - - expect(header).toBeInTheDocument(); - expect(newChatButton.closest(".view-header")).toBeInTheDocument(); - expect(providerIcon).toBeInTheDocument(); - expect(within(identity).getByText("Agent Chat")).toBeInTheDocument(); - expect(modelTag).toBeInTheDocument(); - expect(modelTag).toHaveTextContent("Claude Sonnet 4.5"); - expect(toggle).toBeInTheDocument(); - expect(header?.children[header.children.length - 1]).toBe(toggle); - expect(document.querySelectorAll(".chat-thread-header .chat-model-tag")).toHaveLength(1); - }); - - it("does not show model tag when session has no model", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test Chat", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - { id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag).not.toBeInTheDocument(); - }); - - it("does not repeat the model tag in per-message avatars for non-fn sessions", async () => { - // Per-message model tags were intentionally removed — the model is shown - // once in the thread header. The avatar should still render with the - // agent name (no agent identity collapse for real agents) but no model - // tag inside it. - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Agent Chat", - modelProvider: "openai", - modelId: "gpt-4o", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, - ], - }); - - await renderWithAct(); - - const messageBubble = screen.getByTestId("chat-message-msg-001"); - const avatar = messageBubble.querySelector(".chat-message-avatar") as HTMLElement | null; - expect(avatar).toBeInTheDocument(); - expect(avatar?.querySelector(".chat-model-tag")).toBeNull(); - }); - - it("hides per-message identity entirely for fn agent (model-only) sessions even when model is set", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test Chat", - modelProvider: "openai", - modelId: "gpt-4o", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, - ], - }); - - await renderWithAct(); - - const messageBubble = screen.getByTestId("chat-message-msg-001"); - expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull(); - }); + // Extracted late ChatView interaction describes live in ChatView.core-interactions.test.tsx. }); - -describe("formatModelTag helper function", () => { - // Import the function for testing - we'll test it via the UI behavior instead - // The function is not exported, so we test it indirectly through the component - - it("formats claude-sonnet-4-5 model ID correctly", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Test", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag?.textContent).toContain("Claude Sonnet"); - }); - - it("formats gpt-4o model ID correctly", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Test", - modelProvider: "openai", - modelId: "gpt-4o", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag?.textContent).toContain("GPT-4o"); - }); - - it("formats gemini-2.5-pro model ID correctly", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Test", - modelProvider: "google", - modelId: "gemini-2.5-pro", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag?.textContent).toContain("Gemini"); - }); - - it("returns null when modelId is missing", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test", - modelProvider: "anthropic", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag).not.toBeInTheDocument(); - }); - - it("returns null when provider is missing", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag).not.toBeInTheDocument(); - }); -}); - -describe("Chat Session Delete Button", () => { - it("renders delete button on each session item", async () => { - setupMockChat({ - sessions: [ - { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - { id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - ], - filteredSessions: [ - { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - { id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const deleteButtons = screen.getAllByTestId("chat-session-delete-btn"); - expect(deleteButtons.length).toBe(2); - }); - - it("clicking delete button shows confirmation dialog", async () => { - 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" }], - 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" }], - }); - - await renderWithAct(); - - const deleteButton = screen.getByTestId("chat-session-delete-btn"); - await userEvent.click(deleteButton); - - // Dialog should be open - const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; - expect(dialog).toBeInTheDocument(); - expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); - }); - - it("clicking delete button does not select the session", async () => { - const selectSession = vi.fn(); - 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" }], - 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" }], - selectSession, - }); - - await renderWithAct(); - - const deleteButton = screen.getByTestId("chat-session-delete-btn"); - await userEvent.click(deleteButton); - - expect(selectSession).not.toHaveBeenCalled(); - }); - - it("renames from the desktop context menu with the current title prefilled", async () => { - const renameSession = vi.fn().mockResolvedValue(undefined); - const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Renamed Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; - setupMockChat({ - 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" }, - 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" }], - 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" }], - renameSession, - }); - - const view = await renderWithAct(); - - fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); - expect(screen.getByTestId("chat-context-rename")).toBeInTheDocument(); - await userEvent.click(screen.getByTestId("chat-context-rename")); - - const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; - expect(input.value).toBe("Test Chat"); - await userEvent.clear(input); - await userEvent.type(input, "Renamed Chat"); - await userEvent.click(screen.getByTestId("chat-rename-save")); - - expect(renameSession).toHaveBeenCalledWith("session-001", "Renamed Chat"); - - setupMockChat({ - activeSession: renamedSession, - sessions: [renamedSession], - filteredSessions: [renamedSession], - renameSession, - }); - await act(async () => { - view.rerender(); - }); - - expect(screen.getByTestId("chat-session-session-001")).toHaveTextContent("Renamed Chat"); - const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; - expect(headerTitle).toHaveTextContent("Renamed Chat"); - }); - - it("prefills rename as empty for an untitled session and names it", async () => { - const renameSession = vi.fn().mockResolvedValue(undefined); - const untitledSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: null, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; - setupMockChat({ - activeSession: untitledSession, - sessions: [untitledSession], - filteredSessions: [untitledSession], - renameSession, - }); - - await renderWithAct(); - - fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); - await userEvent.click(screen.getByTestId("chat-context-rename")); - - const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; - expect(input.value).toBe(""); - await userEvent.type(input, "Named from Untitled"); - await userEvent.click(screen.getByTestId("chat-rename-save")); - - expect(renameSession).toHaveBeenCalledWith("session-001", "Named from Untitled"); - }); - - it("renames from the mobile session switcher and preserves the active header title surface", async () => { - const restoreMatchMedia = mockViewportMode("mobile"); - const renameSession = vi.fn().mockResolvedValue(undefined); - try { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - renameSession, - }); - - const view = await renderWithAct(); - - expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Chat"); - await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); - await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-001")); - - const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; - expect(input.value).toBe("Mobile Chat"); - await userEvent.clear(input); - await userEvent.type(input, "Mobile Renamed"); - await userEvent.click(screen.getByTestId("chat-rename-save")); - - expect(renameSession).toHaveBeenCalledWith("session-001", "Mobile Renamed"); - - const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Renamed", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; - setupMockChat({ - activeSession: renamedSession, - sessions: [renamedSession], - filteredSessions: [renamedSession], - renameSession, - }); - await act(async () => { - view.rerender(); - }); - - expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Renamed"); - const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; - expect(headerTitle).toHaveTextContent("Mobile Renamed"); - } finally { - restoreMatchMedia.mockRestore(); - } - }); - - it("confirming delete calls deleteSession", async () => { - const deleteSession = vi.fn(); - 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" }], - 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" }], - deleteSession, - }); - - await renderWithAct(); - - const deleteButton = screen.getByTestId("chat-session-delete-btn"); - await userEvent.click(deleteButton); - - // Click confirm in dialog - const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; - await userEvent.click(within(dialog!).getByText("Delete")); - - expect(deleteSession).toHaveBeenCalledWith("session-001"); - }); -}); - -describe("ChatView CSS — failure bubble contracts", () => { - const css = loadAllAppCss(); - - it("uses shared error surface tokens for failure bubbles and detail affordances", async () => { - const bubbleMatch = css.match(/\.chat-message--failure\s*\{([^}]*)\}/); - const badgeMatch = css.match(/\.chat-message-failure-badge\s*\{([^}]*)\}/); - const detailsMatch = css.match(/\.chat-message-failure-details\s*\{([^}]*)\}/); - const linkMatch = css.match(/\.chat-message-failure-reference-link\s*\{([^}]*)\}/); - - expect(bubbleMatch?.[1]).toContain("background: var(--status-error-bg)"); - expect(bubbleMatch?.[1]).toContain("border: var(--btn-border-width) solid var(--status-error-bg-deep)"); - expect(badgeMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); - expect(detailsMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); - expect(linkMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); - }); -}); - -describe("ChatView CSS — tablet assistant bubble width", () => { - const css = loadAllAppCss(); - - it("widens assistant, streaming, and failure bubbles on tablet containers while preserving user and mobile caps", async () => { - const baseMessageRule = css.match(/\.chat-message\s*\{([^}]*)\}/); - const userRule = css.match(/\.chat-message--user\s*\{([^}]*)\}/); - const tabletRule = css.match( - /@container\s+chat-view\s+\(min-width:\s*48\.0625rem\)\s+and\s+\(max-width:\s*64rem\)\s*\{([\s\S]*?)\n\}/, - ); - - expect(baseMessageRule?.[1]).toContain("max-width: 75%"); - expect(userRule?.[1]).toContain("align-self: flex-end"); - expect(userRule?.[1]).not.toContain("max-width"); - expect(tabletRule?.[1]).toMatch( - /\.chat-message--assistant,\s*\.chat-message--streaming,\s*\.chat-message--failure\s*\{[^}]*max-width:\s*88%/, - ); - expect(tabletRule?.[1]).not.toMatch(/\.chat-message--user\s*\{[^}]*max-width/); - expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*90%/); - }); -}); - -describe("ChatView CSS — active state edge highlights", () => { - const css = loadAllAppCss(); - - function findRule(selector: string): string { - const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); - expect(match).toBeTruthy(); - return match?.[1] ?? ""; - } - - function mobileRuleContains(selector: string, propertyPattern: RegExp): boolean { - const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const mobileRegex = /@media[^{}]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; - let match; - while ((match = mobileRegex.exec(css)) !== null) { - const ruleMatch = match[1].match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); - if (ruleMatch && propertyPattern.test(ruleMatch[1])) { - return true; - } - } - return false; - } - - it("keeps scope-tab active tint without the removed bottom underline", async () => { - const activeScopeRule = findRule(".chat-sidebar-scope-btn--active"); - - expect(activeScopeRule).toContain("background: var(--card)"); - expect(activeScopeRule).toContain("color: var(--text)"); - expect(activeScopeRule).not.toContain("box-shadow"); - expect(activeScopeRule).not.toContain("inset"); - }); - - it("renders the header Direct/Rooms toggle with visible borders", async () => { - const headerScopeRule = findRule(".chat-view-header-scope-toggle"); - const headerScopeButtonRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn"); - const headerActiveScopeRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn--active"); - - expect(headerScopeRule).toContain("border: 1px solid var(--border)"); - expect(headerScopeRule).toContain("height: var(--view-header-content-row, 28px)"); - expect(headerScopeButtonRule).toContain("border: 1px solid transparent"); - expect(headerScopeButtonRule).toContain("height: 100%"); - expect(headerActiveScopeRule).toContain("border-color: var(--todo)"); - }); - - it("collapses header Direct/Rooms labels to icons at very narrow widths", async () => { - expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px/); - expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip:\s*rect\(0 0 0 0\)/); - }); - - it("keeps active chat-row background without the removed left edge or offset", async () => { - const activeSessionRule = findRule(".chat-session-item--active"); - - expect(activeSessionRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent)"); - expect(activeSessionRule).not.toContain("border-left"); - expect(activeSessionRule).not.toContain("padding-left: calc(var(--space-md) - (var(--btn-border-width) * 3))"); - }); - - it("does not reintroduce either removed highlight in mobile rules", async () => { - expect(mobileRuleContains(".chat-sidebar-scope-btn--active", /box-shadow\s*:\s*inset/)).toBe(false); - expect(mobileRuleContains(".chat-session-item--active", /border-left\s*:/)).toBe(false); - expect(mobileRuleContains(".chat-session-item--active", /padding-left\s*:\s*calc\(var\(--space-md\)\s*-\s*\(var\(--btn-border-width\)\s*\*\s*3\)\)/)).toBe(false); - }); -}); - -describe("FN-3911 chat session list layout", () => { - const css = loadAllAppCss(); - - it("reserves right padding on title and preview rows so text clears the delete button", async () => { - const titleMatch = css.match(/\.chat-session-title\s*\{([^}]*)\}/); - const previewMatch = css.match(/\.chat-session-preview\s*\{([^}]*)\}/); - expect(titleMatch).toBeTruthy(); - expect(previewMatch).toBeTruthy(); - expect(titleMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); - expect(previewMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); - }); - - it("FN-4385: keeps mobile title/preview clearance matched to compact delete button", async () => { - expect(css).toMatch( - /@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-session-title,\s*\.chat-session-preview\s*\{\s*padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\);\s*\}/, - ); - }); -}); - -describe("Chat Session Delete Button CSS", () => { - const css = loadAllAppCss(); - - it(".chat-session-delete-btn exists with opacity: 0", async () => { - const match = css.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("opacity: 0"); - }); - - it(".chat-session-item:hover .chat-session-delete-btn has opacity: 1", async () => { - const match = css.match(/\.chat-session-item:hover\s*\.chat-session-delete-btn\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("opacity: 1"); - }); - - it("FN-4352: mobile delete button stays visible without min-size inflation", async () => { - const mobileRegex = /@media[^{]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; - let match; - let deleteRule = ""; - while ((match = mobileRegex.exec(css)) !== null) { - const mediaContent = match[1]; - if (mediaContent.includes(".chat-session-delete-btn")) { - deleteRule = mediaContent.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/)?.[1] ?? ""; - if (deleteRule) break; - } - } - - expect(deleteRule).toContain("opacity: 1"); - expect(deleteRule).not.toContain("min-width:"); - expect(deleteRule).not.toContain("min-height:"); - }); -}); - -describe("ChatView CSS — mobile thread switcher", () => { - const css = loadAllAppCss(); - - it("includes mobile session switcher trigger and dropdown tokenized contracts", async () => { - const triggerMatch = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/); - const triggerIconMatch = css.match(/\.chat-mobile-session-trigger\s*>\s*svg\s*\{([^}]*)\}/); - const dropdownMatch = css.match(/\.chat-mobile-session-dropdown\s*\{([^}]*)\}/); - const optionMatch = css.match(/\.chat-mobile-session-option\s*\{([^}]*)\}/); - const optionTitleMatch = css.match(/\.chat-mobile-session-option-title\s*\{([^}]*)\}/); - expect(triggerMatch).toBeTruthy(); - expect(triggerIconMatch).toBeTruthy(); - expect(dropdownMatch).toBeTruthy(); - expect(optionMatch).toBeTruthy(); - expect(optionTitleMatch).toBeTruthy(); - expect(triggerMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs))"); - expect(triggerMatch?.[1]).toContain("min-width: 0"); - expect(triggerMatch?.[1]).toContain("padding: var(--space-xs) var(--space-sm)"); - expect(triggerMatch?.[1]).toContain("font: inherit"); - expect(triggerMatch?.[1]).toContain("line-height: normal"); - expect(triggerMatch?.[1]).toContain("text-align: left"); - expect(triggerIconMatch?.[1]).toContain("width: var(--icon-size-md)"); - expect(triggerIconMatch?.[1]).toContain("height: var(--icon-size-md)"); - expect(dropdownMatch?.[1]).toContain("background: var(--surface)"); - expect(dropdownMatch?.[1]).toContain("border: 1px solid var(--border)"); - expect(optionMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2.25)"); - expect(optionMatch?.[1]).toContain("align-items: flex-start"); - expect(optionMatch?.[1]).toContain("line-height: normal"); - expect(optionTitleMatch?.[1]).toContain("display: block"); - expect(optionTitleMatch?.[1]).toContain("line-height: normal"); - expect(optionTitleMatch?.[1]).toContain("white-space: normal"); - expect(optionTitleMatch?.[1]).toContain("overflow-wrap: anywhere"); - }); - - it("keeps mobile override for header identity overflow visible so dropdown can render", async () => { - 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(); - - it(".chat-session-list has min-height: 0 for proper vertical scrolling", async () => { - const match = css.match(/\.chat-session-list\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("min-height: 0"); - }); - - it(".chat-thread has min-height: 0 for proper vertical scrolling", async () => { - const match = css.match(/\.chat-thread\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("min-height: 0"); - }); - - it(".chat-messages has min-height: 0 for proper vertical scrolling", async () => { - const match = css.match(/\.chat-messages\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("min-height: 0"); - }); -}); - diff --git a/packages/engine/src/__tests__/notifier.runtime.test.ts b/packages/engine/src/__tests__/notifier.runtime.test.ts new file mode 100644 index 0000000000..084581bfd7 --- /dev/null +++ b/packages/engine/src/__tests__/notifier.runtime.test.ts @@ -0,0 +1,810 @@ +/* +FNXC:EngineTests 2026-06-25-17:44: +Notifier runtime suite split extracts the later NtfyNotifier reconfiguration, error, deduplication, runtime wiring, URL, stop, edge-case, and event-filtering describe blocks from notifier.test.ts so both sibling suites stay under MAX_LINES without weakening assertions. +*/ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { MergeResult } from "@fusion/core"; +import { NtfyNotifier, notifyFallbackUsed } from "../notifier.js"; +import { NotificationService } from "../notification/notification-service.js"; +import { MockTaskStore, createTask, flushAsyncWork } from "./notifier.test-harness.js"; + +vi.mock("../logger.js", () => ({ + schedulerLog: { log: vi.fn(), error: vi.fn() }, +})); + +describe("NtfyNotifier runtime behaviors", () => { + let store: MockTaskStore; + let notifier: NtfyNotifier; + let fetchMock: ReturnType; + + beforeEach(async () => { + store = new MockTaskStore(); + fetchMock = vi.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + if (notifier) { + notifier.stop(); + } + vi.restoreAllMocks(); + }); + + describe("runtime reconfiguration", () => { + it("starts sending notifications when enabled at runtime", async () => { + store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Initially disabled + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).not.toHaveBeenCalled(); + + // Enable at runtime + fetchMock.mockResolvedValue({ ok: true }); + store.setSettings({ ntfyEnabled: true }); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("stops sending notifications when disabled at runtime", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Initially enabled + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Disable at runtime + store.setSettings({ ntfyEnabled: false }); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls + }); + + it("uses updated topic when changed at runtime", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object)); + + // Change topic + store.setSettings({ ntfyTopic: "new-topic" }); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object)); + }); + }); + + describe("error handling", () => { + it("catches and logs fetch errors without throwing", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockRejectedValue(new Error("Network error")); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Should not throw + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalled(); + }); + + it("handles HTTP error responses without throwing", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Should not throw + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalled(); + }); + }); + + describe("deduplication", () => { + beforeEach(() => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + }); + + it("prevents duplicate notifications for the same event type", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + + // Multiple in-review events for the same task + store.triggerTaskMoved(task, "in-progress", "in-review"); + store.triggerTaskMoved(task, "in-progress", "in-review"); + store.triggerTaskMoved(task, "in-progress", "in-review"); + + await flushAsyncWork(); + + // Should only send one notification due to deduplication + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("prevents duplicate awaiting-approval notifications for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-004", "Approval Task", "awaiting-approval"); + + store.triggerTaskUpdated(task); + store.triggerTaskUpdated(task); + store.triggerTaskUpdated(task); + + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + "Title": "Plan needs approval for FN-004", + }), + }), + ); + }); + + it("prevents duplicate awaiting-user-review notifications for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-005", "User Review Task", "awaiting-user-review"); + + store.triggerTaskUpdated(task); + store.triggerTaskUpdated(task); + store.triggerTaskUpdated(task); + + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + "Title": "User review needed for FN-005", + }), + }), + ); + }); + + it("allows different event types for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + + // First: in-review notification + store.triggerTaskMoved(task, "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Second: merged notification (different event type - should be allowed) + const mergeResult: MergeResult = { + task, + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + + // Should have two notifications now + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("allows awaiting-approval alongside other event types for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-005", "Approval + Failure"); + + store.triggerTaskUpdated({ ...task, status: "awaiting-approval" }); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + store.triggerTaskUpdated({ ...task, status: "failed" }); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("sends notification only once on merge when task:moved and task:merged both fire", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + const mergeResult: MergeResult = { + task, + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + + // completeTask() emits task:moved to done before task:merged + store.triggerTaskMoved(task, "in-review", "done"); + store.triggerTaskMerged(mergeResult); + + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Title": "Task FN-001 merged", + "Priority": "default", + }), + body: 'Task "Test Task" has been merged to main', + }) + ); + }); + + it("prevents duplicate task:merged events for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + const mergeResult: MergeResult = { + task, + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + + // Multiple merged events for the same task + store.triggerTaskMerged(mergeResult); + store.triggerTaskMerged(mergeResult); + store.triggerTaskMerged(mergeResult); + + await flushAsyncWork(); + + // Should only send one notification + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => { + const sharedService = new NotificationService(store, { projectId: "proj-1" }); + await sharedService.start(); + + notifier = new NtfyNotifier(store, { projectId: "proj-1" }, sharedService); + await notifier.start(); + + const task = createTask("FN-777", "Single Merge Notification"); + const mergeResult: MergeResult = { + task, + branch: "fusion/fn-777", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + Title: "Task FN-777 merged", + }), + }), + ); + + await sharedService.stop(); + }); + + it("dispatches and deduplicates fallback-used notifications", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + await notifyFallbackUsed({ + primaryModel: "anthropic/claude-sonnet-4-5", + fallbackModel: "openai/gpt-4o", + triggerPoint: "session-creation", + taskId: "FN-900", + taskTitle: "Fallback task", + }); + await notifyFallbackUsed({ + primaryModel: "anthropic/claude-sonnet-4-5", + fallbackModel: "openai/gpt-4o", + triggerPoint: "session-creation", + taskId: "FN-900", + taskTitle: "Fallback task", + }); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"), + }), + ); + }); + + it("allows notifications for different tasks independently", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task1 = createTask("FN-001", "Test Task 1"); + const task2 = createTask("FN-002", "Test Task 2"); + + store.triggerTaskMoved(task1, "in-progress", "in-review"); + store.triggerTaskMoved(task2, "in-progress", "in-review"); + + await flushAsyncWork(); + + // Different tasks should each get their own notification + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("dashboard runtime wiring", () => { + /** + * These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts + * where the NtfyNotifier is constructed with an optional projectId resolved + * from the central project registry. When a registered project is found, + * deep links include ?project=...&task=...; when no project is registered + * (legacy / single-project mode), links fall back to ?task=... only. + */ + beforeEach(() => { + fetchMock.mockResolvedValue({ ok: true }); + }); + + it("produces project-aware deep links when constructed with registered project ID", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyDashboardHost: "http://localhost:3000", + }); + + // Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id }); + notifier = new NtfyNotifier(store, { projectId: "proj_abc123" }); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + "Click": "http://localhost:3000/?project=proj_abc123&task=FN-001", + }), + }), + ); + }); + + it("produces task-only deep links when no project ID is available (legacy mode)", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyDashboardHost: "http://localhost:3000", + }); + + // Simulates: const notifier = new NtfyNotifier(store); // no projectId + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + "Click": "http://localhost:3000/?task=FN-001", + }), + }), + ); + // Verify no "project=" in the URL + const callArgs = fetchMock.mock.calls[0][1] as { headers: Record }; + expect(callArgs.headers["Click"]).not.toContain("project="); + }); + + it("produces project-aware deep links for all notification event types", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyDashboardHost: "https://fusion.example.com", + }); + + notifier = new NtfyNotifier(store, { projectId: "proj_xyz" }); + await notifier.start(); + + // in-review event + store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review"); + await flushAsyncWork(); + + // merged event + const mergeResult: MergeResult = { + task: createTask("FN-001", "Task A"), + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + + // Verify both calls include project + const calls = fetchMock.mock.calls; + for (const call of calls) { + const headers = call[1].headers as Record; + expect(headers["Click"]).toContain("project=proj_xyz"); + } + }); + }); + + describe("custom base URL", () => { + it("uses custom ntfy base URL when provided in notifier options", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" }); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://my-ntfy.example.com/test-topic", + expect.any(Object) + ); + }); + + it("uses ntfyBaseUrl from settings when configured", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyBaseUrl: "https://ntfy.internal.example///", + }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.internal.example/test-topic", + expect.any(Object), + ); + }); + + it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyBaseUrl: " ", + }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.any(Object), + ); + }); + + it("applies updated ntfyBaseUrl from settings changes at runtime", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object)); + + store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" }); + store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenLastCalledWith( + "https://ntfy.changed.example/test-topic", + expect.any(Object), + ); + }); + }); + + describe("stop()", () => { + it("stops listening to events after stop() is called", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + notifier.stop(); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + + // Should not increase after stop + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("edge cases", () => { + it("allows in-review and failed notifications for the same task", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + + // First: in-review notification + store.triggerTaskMoved(task, "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Second: failed notification (different event type - should be allowed) + const failedTask = { ...task, status: "failed" }; + store.triggerTaskUpdated(failedTask); + await flushAsyncWork(); + + // Should have two notifications + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not notify on task:moved to columns other than in-review", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Move to todo - should not notify + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo"); + await flushAsyncWork(); + + // Move to in-progress - should not notify + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress"); + await flushAsyncWork(); + + // Move to done - should not notify (merged notification comes from task:merged) + store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done"); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not notify on task:updated when status is neither failed nor awaiting-approval", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task", "in-progress"); + store.triggerTaskUpdated(task); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("handles empty topic gracefully", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + // Empty topic should be treated as no topic + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("event filtering", () => { + beforeEach(() => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + }); + + it("does not send in-review notification when 'in-review' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["merged", "failed", "awaiting-approval"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not send merged notification when 'merged' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "failed", "awaiting-approval"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const mergeResult: MergeResult = { + task: createTask("FN-001", "Test Task"), + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not send failed notification when 'failed' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "awaiting-approval"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const failedTask = createTask("FN-001", "Test Task", "failed"); + store.triggerTaskUpdated(failedTask); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not send awaiting-approval notification when 'awaiting-approval' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const awaitingApprovalTask = createTask("FN-006", "Approval Task", "awaiting-approval"); + store.triggerTaskUpdated(awaitingApprovalTask); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review"); + store.triggerTaskUpdated(awaitingUserReviewTask); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("sends notification for enabled events while others are disabled", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // in-review - should send + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // merged - should NOT send + const mergeResult: MergeResult = { + task: createTask("FN-002", "Test Task 2"), + branch: "fusion/fn-002", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call + + // failed - should NOT send + const failedTask = createTask("FN-003", "Test Task 3", "failed"); + store.triggerTaskUpdated(failedTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call + + // awaiting-approval - should NOT send + const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval"); + store.triggerTaskUpdated(awaitingApprovalTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call + + // awaiting-user-review - should NOT send + const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review"); + store.triggerTaskUpdated(awaitingUserReviewTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call + }); + + it("defaults to all events when ntfyEvents is undefined", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: undefined }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const mergeResult: MergeResult = { + task: createTask("FN-002", "Test Task 2"), + branch: "fusion/fn-002", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const failedTask = createTask("FN-003", "Test Task 3", "failed"); + store.triggerTaskUpdated(failedTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(3); + + const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval"); + store.triggerTaskUpdated(awaitingApprovalTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(4); + + const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review"); + store.triggerTaskUpdated(awaitingUserReviewTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(5); + }); + + it("updates notifications when ntfyEvents changes at runtime", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Initially all events enabled + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Disable in-review + store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] }); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review + + // Enable in-review again + store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] }); + + store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(2); // New call for in-review + }); + }); +}); diff --git a/packages/engine/src/__tests__/notifier.test-harness.ts b/packages/engine/src/__tests__/notifier.test-harness.ts new file mode 100644 index 0000000000..593df3d424 --- /dev/null +++ b/packages/engine/src/__tests__/notifier.test-harness.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from "node:events"; +import { expect, vi } from "vitest"; +import type { Task, Column, MergeResult, Settings } from "@fusion/core"; + +/* +FNXC:EngineTests 2026-06-25-17:44: +Shared notifier test harness for the FN-7035 suite split. MockTaskStore, createTask, and flushAsyncWork stay in one helper so notifier.test.ts and notifier.runtime.test.ts can split whole describe blocks under the line-count cap without duplicating event-store behavior. +*/ + +interface MockTaskStoreEvents { + "task:moved": [{ task: Task; from: Column; to: Column }]; + "task:updated": [Task]; + "task:merged": [MergeResult]; + "settings:updated": [{ settings: Settings; previous: Settings }]; +} + +export async function flushAsyncWork(): Promise { + await vi.waitFor(() => { + expect(true).toBe(true); + }); +} + +export class MockTaskStore extends EventEmitter { + private settings: Settings = { + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + groupOverlappingFiles: false, + autoMerge: true, + ntfyEnabled: false, + ntfyTopic: undefined, + failureNotificationMode: "all", + failureNotificationDelayMs: 0, + }; + + getSettings(): Settings { + return { ...this.settings }; + } + + setSettings(settings: Partial): void { + const previous = { ...this.settings }; + this.settings = { ...this.settings, ...settings }; + this.emit("settings:updated", { settings: this.settings, previous }); + } + + triggerTaskMoved(task: Task, from: Column, to: Column): void { + this.emit("task:moved", { task, from, to }); + } + + triggerTaskUpdated(task: Task): void { + this.emit("task:updated", task); + } + + triggerTaskMerged(result: MergeResult): void { + this.emit("task:merged", result); + } +} + +export const createTask = (id: string, title?: string, status?: string): Task => ({ + id, + title, + description: "Test task", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + status, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + log: [], +}); diff --git a/packages/engine/src/__tests__/notifier.test.ts b/packages/engine/src/__tests__/notifier.test.ts index 7d955f6c16..e14322a584 100644 --- a/packages/engine/src/__tests__/notifier.test.ts +++ b/packages/engine/src/__tests__/notifier.test.ts @@ -1,73 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { EventEmitter } from "node:events"; -import type { Task, Column, MergeResult, Settings } from "@fusion/core"; +import type { MergeResult } from "@fusion/core"; import { NtfyNotifier, DEFAULT_NTFY_EVENTS, buildNtfyClickUrl, isNtfyEventEnabled, resolveNtfyEvents, - notifyFallbackUsed, sendNtfyNotificationWithResult, } from "../notifier.js"; -import { NotificationService } from "../notification/notification-service.js"; import { NtfyNotificationProvider } from "../notification/ntfy-provider.js"; +import { MockTaskStore, createTask, flushAsyncWork } from "./notifier.test-harness.js"; -// Mock the logger vi.mock("../logger.js", () => ({ schedulerLog: { log: vi.fn(), error: vi.fn() }, })); -interface MockTaskStoreEvents { - "task:moved": [{ task: Task; from: Column; to: Column }]; - "task:updated": [Task]; - "task:merged": [MergeResult]; - "settings:updated": [{ settings: Settings; previous: Settings }]; -} - -async function flushAsyncWork(): Promise { - await vi.waitFor(() => { - expect(true).toBe(true); - }); -} - -class MockTaskStore extends EventEmitter { - private settings: Settings = { - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: true, - ntfyEnabled: false, - ntfyTopic: undefined, - failureNotificationMode: "all", - failureNotificationDelayMs: 0, - }; - - getSettings(): Settings { - return { ...this.settings }; - } - - setSettings(settings: Partial): void { - const previous = { ...this.settings }; - this.settings = { ...this.settings, ...settings }; - this.emit("settings:updated", { settings: this.settings, previous }); - } - - // Helper to trigger events - triggerTaskMoved(task: Task, from: Column, to: Column): void { - this.emit("task:moved", { task, from, to }); - } - - triggerTaskUpdated(task: Task): void { - this.emit("task:updated", task); - } - - triggerTaskMerged(result: MergeResult): void { - this.emit("task:merged", result); - } -} - describe("Ntfy notifier helpers", () => { it("includes mailbox message events in default events", () => { expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input"); @@ -589,20 +536,6 @@ describe("NtfyNotifier", () => { vi.restoreAllMocks(); }); - const createTask = (id: string, title?: string, status?: string): Task => ({ - id, - title, - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - status, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - log: [], - }); - describe("when disabled", () => { it("does not send any notifications when ntfyEnabled is false", async () => { store.setSettings({ ntfyEnabled: false, ntfyTopic: "my-topic" }); @@ -1325,780 +1258,4 @@ describe("NtfyNotifier", () => { }); }); - describe("runtime reconfiguration", () => { - it("starts sending notifications when enabled at runtime", async () => { - store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Initially disabled - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).not.toHaveBeenCalled(); - - // Enable at runtime - fetchMock.mockResolvedValue({ ok: true }); - store.setSettings({ ntfyEnabled: true }); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("stops sending notifications when disabled at runtime", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Initially enabled - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Disable at runtime - store.setSettings({ ntfyEnabled: false }); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls - }); - - it("uses updated topic when changed at runtime", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object)); - - // Change topic - store.setSettings({ ntfyTopic: "new-topic" }); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object)); - }); - }); - - describe("error handling", () => { - it("catches and logs fetch errors without throwing", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockRejectedValue(new Error("Network error")); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Should not throw - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalled(); - }); - - it("handles HTTP error responses without throwing", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Should not throw - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalled(); - }); - }); - - describe("deduplication", () => { - beforeEach(() => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - }); - - it("prevents duplicate notifications for the same event type", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - - // Multiple in-review events for the same task - store.triggerTaskMoved(task, "in-progress", "in-review"); - store.triggerTaskMoved(task, "in-progress", "in-review"); - store.triggerTaskMoved(task, "in-progress", "in-review"); - - await flushAsyncWork(); - - // Should only send one notification due to deduplication - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("prevents duplicate awaiting-approval notifications for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-004", "Approval Task", "awaiting-approval"); - - store.triggerTaskUpdated(task); - store.triggerTaskUpdated(task); - store.triggerTaskUpdated(task); - - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - "Title": "Plan needs approval for FN-004", - }), - }), - ); - }); - - it("prevents duplicate awaiting-user-review notifications for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-005", "User Review Task", "awaiting-user-review"); - - store.triggerTaskUpdated(task); - store.triggerTaskUpdated(task); - store.triggerTaskUpdated(task); - - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - "Title": "User review needed for FN-005", - }), - }), - ); - }); - - it("allows different event types for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - - // First: in-review notification - store.triggerTaskMoved(task, "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Second: merged notification (different event type - should be allowed) - const mergeResult: MergeResult = { - task, - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - - // Should have two notifications now - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("allows awaiting-approval alongside other event types for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-005", "Approval + Failure"); - - store.triggerTaskUpdated({ ...task, status: "awaiting-approval" }); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - store.triggerTaskUpdated({ ...task, status: "failed" }); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("sends notification only once on merge when task:moved and task:merged both fire", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - const mergeResult: MergeResult = { - task, - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - - // completeTask() emits task:moved to done before task:merged - store.triggerTaskMoved(task, "in-review", "done"); - store.triggerTaskMerged(mergeResult); - - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Title": "Task FN-001 merged", - "Priority": "default", - }), - body: 'Task "Test Task" has been merged to main', - }) - ); - }); - - it("prevents duplicate task:merged events for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - const mergeResult: MergeResult = { - task, - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - - // Multiple merged events for the same task - store.triggerTaskMerged(mergeResult); - store.triggerTaskMerged(mergeResult); - store.triggerTaskMerged(mergeResult); - - await flushAsyncWork(); - - // Should only send one notification - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => { - const sharedService = new NotificationService(store, { projectId: "proj-1" }); - await sharedService.start(); - - notifier = new NtfyNotifier(store, { projectId: "proj-1" }, sharedService); - await notifier.start(); - - const task = createTask("FN-777", "Single Merge Notification"); - const mergeResult: MergeResult = { - task, - branch: "fusion/fn-777", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - Title: "Task FN-777 merged", - }), - }), - ); - - await sharedService.stop(); - }); - - it("dispatches and deduplicates fallback-used notifications", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - await notifyFallbackUsed({ - primaryModel: "anthropic/claude-sonnet-4-5", - fallbackModel: "openai/gpt-4o", - triggerPoint: "session-creation", - taskId: "FN-900", - taskTitle: "Fallback task", - }); - await notifyFallbackUsed({ - primaryModel: "anthropic/claude-sonnet-4-5", - fallbackModel: "openai/gpt-4o", - triggerPoint: "session-creation", - taskId: "FN-900", - taskTitle: "Fallback task", - }); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"), - }), - ); - }); - - it("allows notifications for different tasks independently", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task1 = createTask("FN-001", "Test Task 1"); - const task2 = createTask("FN-002", "Test Task 2"); - - store.triggerTaskMoved(task1, "in-progress", "in-review"); - store.triggerTaskMoved(task2, "in-progress", "in-review"); - - await flushAsyncWork(); - - // Different tasks should each get their own notification - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - }); - - describe("dashboard runtime wiring", () => { - /** - * These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts - * where the NtfyNotifier is constructed with an optional projectId resolved - * from the central project registry. When a registered project is found, - * deep links include ?project=...&task=...; when no project is registered - * (legacy / single-project mode), links fall back to ?task=... only. - */ - beforeEach(() => { - fetchMock.mockResolvedValue({ ok: true }); - }); - - it("produces project-aware deep links when constructed with registered project ID", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyDashboardHost: "http://localhost:3000", - }); - - // Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id }); - notifier = new NtfyNotifier(store, { projectId: "proj_abc123" }); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - "Click": "http://localhost:3000/?project=proj_abc123&task=FN-001", - }), - }), - ); - }); - - it("produces task-only deep links when no project ID is available (legacy mode)", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyDashboardHost: "http://localhost:3000", - }); - - // Simulates: const notifier = new NtfyNotifier(store); // no projectId - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - "Click": "http://localhost:3000/?task=FN-001", - }), - }), - ); - // Verify no "project=" in the URL - const callArgs = fetchMock.mock.calls[0][1] as { headers: Record }; - expect(callArgs.headers["Click"]).not.toContain("project="); - }); - - it("produces project-aware deep links for all notification event types", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyDashboardHost: "https://fusion.example.com", - }); - - notifier = new NtfyNotifier(store, { projectId: "proj_xyz" }); - await notifier.start(); - - // in-review event - store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review"); - await flushAsyncWork(); - - // merged event - const mergeResult: MergeResult = { - task: createTask("FN-001", "Task A"), - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - - // Verify both calls include project - const calls = fetchMock.mock.calls; - for (const call of calls) { - const headers = call[1].headers as Record; - expect(headers["Click"]).toContain("project=proj_xyz"); - } - }); - }); - - describe("custom base URL", () => { - it("uses custom ntfy base URL when provided in notifier options", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" }); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://my-ntfy.example.com/test-topic", - expect.any(Object) - ); - }); - - it("uses ntfyBaseUrl from settings when configured", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyBaseUrl: "https://ntfy.internal.example///", - }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.internal.example/test-topic", - expect.any(Object), - ); - }); - - it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyBaseUrl: " ", - }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.any(Object), - ); - }); - - it("applies updated ntfyBaseUrl from settings changes at runtime", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object)); - - store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" }); - store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenLastCalledWith( - "https://ntfy.changed.example/test-topic", - expect.any(Object), - ); - }); - }); - - describe("stop()", () => { - it("stops listening to events after stop() is called", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - notifier.stop(); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - - // Should not increase after stop - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - }); - - describe("edge cases", () => { - it("allows in-review and failed notifications for the same task", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - - // First: in-review notification - store.triggerTaskMoved(task, "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Second: failed notification (different event type - should be allowed) - const failedTask = { ...task, status: "failed" }; - store.triggerTaskUpdated(failedTask); - await flushAsyncWork(); - - // Should have two notifications - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("does not notify on task:moved to columns other than in-review", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Move to todo - should not notify - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo"); - await flushAsyncWork(); - - // Move to in-progress - should not notify - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress"); - await flushAsyncWork(); - - // Move to done - should not notify (merged notification comes from task:merged) - store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done"); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not notify on task:updated when status is neither failed nor awaiting-approval", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task", "in-progress"); - store.triggerTaskUpdated(task); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("handles empty topic gracefully", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - // Empty topic should be treated as no topic - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); - - describe("event filtering", () => { - beforeEach(() => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - }); - - it("does not send in-review notification when 'in-review' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["merged", "failed", "awaiting-approval"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not send merged notification when 'merged' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "failed", "awaiting-approval"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const mergeResult: MergeResult = { - task: createTask("FN-001", "Test Task"), - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not send failed notification when 'failed' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "awaiting-approval"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const failedTask = createTask("FN-001", "Test Task", "failed"); - store.triggerTaskUpdated(failedTask); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not send awaiting-approval notification when 'awaiting-approval' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const awaitingApprovalTask = createTask("FN-006", "Approval Task", "awaiting-approval"); - store.triggerTaskUpdated(awaitingApprovalTask); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review"); - store.triggerTaskUpdated(awaitingUserReviewTask); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("sends notification for enabled events while others are disabled", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // in-review - should send - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // merged - should NOT send - const mergeResult: MergeResult = { - task: createTask("FN-002", "Test Task 2"), - branch: "fusion/fn-002", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call - - // failed - should NOT send - const failedTask = createTask("FN-003", "Test Task 3", "failed"); - store.triggerTaskUpdated(failedTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call - - // awaiting-approval - should NOT send - const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval"); - store.triggerTaskUpdated(awaitingApprovalTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call - - // awaiting-user-review - should NOT send - const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review"); - store.triggerTaskUpdated(awaitingUserReviewTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call - }); - - it("defaults to all events when ntfyEvents is undefined", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: undefined }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - const mergeResult: MergeResult = { - task: createTask("FN-002", "Test Task 2"), - branch: "fusion/fn-002", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(2); - - const failedTask = createTask("FN-003", "Test Task 3", "failed"); - store.triggerTaskUpdated(failedTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(3); - - const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval"); - store.triggerTaskUpdated(awaitingApprovalTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(4); - - const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review"); - store.triggerTaskUpdated(awaitingUserReviewTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(5); - }); - - it("updates notifications when ntfyEvents changes at runtime", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Initially all events enabled - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Disable in-review - store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] }); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review - - // Enable in-review again - store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] }); - - store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(2); // New call for in-review - }); - }); }); diff --git a/scripts/check-file-line-count.mjs b/scripts/check-file-line-count.mjs index 8cb0eb7692..b153696adf 100644 --- a/scripts/check-file-line-count.mjs +++ b/scripts/check-file-line-count.mjs @@ -25,6 +25,9 @@ FN-6917 re-confirms the `pnpm test`-blocking premise is stale because FN-5048 le FNXC:CI 2026-06-25-00:00: FN-7013 re-confirms the `pnpm test`-blocking premise is stale: FN-5048 removed this guard from pretest and left it opt-in under `check:line-count` only. Sixty-one current violations were re-ratcheted after organic feature/test growth and eight stale baseline entries were tightened or pruned. `AgentLogViewer.test.tsx` and `merger-ai.ts` were temporarily grandfathered after crossing the hard cap as long-existing files, with focused split follow-ups FN-7028 and FN-7029. Wholesale god-file shrink/refactor remains the long-term direction and stays deferred to dedicated follow-ups. + +FNXC:CI 2026-06-25-17:44: +FN-7035 split the two new hard-cap crossers (`ChatView.core.test.tsx` and `notifier.test.ts`) into focused sibling suites rather than grandfathering them. Six existing grandfathered entries were re-ratcheted to current counts after organic test and feature growth; `store.ts` and `types.ts` drift was left out of scope for a follow-up. Wholesale god-file shrink remains long-term deferred work for dedicated refactors. */ // Repo-wide guard: hand-written source files may not exceed a hard line-count // cap (MAX_LINES). This stops the next god-file from being born while leaving diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 07537c5e15..17f3d9c0ad 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -7,19 +7,19 @@ "packages/cli/src/commands/dashboard-tui/app.tsx": 4681, "packages/cli/src/commands/dashboard.ts": 3000, "packages/cli/src/extension.ts": 4704, - "packages/core/src/__tests__/agent-store.test.ts": 2997, + "packages/core/src/__tests__/agent-store.test.ts": 3003, "packages/core/src/__tests__/central-core.test.ts": 3263, "packages/core/src/__tests__/db.test.ts": 3606, - "packages/core/src/__tests__/mission-store.test.ts": 4519, + "packages/core/src/__tests__/mission-store.test.ts": 4525, "packages/core/src/__tests__/plugin-loader.test.ts": 2783, "packages/core/src/__tests__/store-settings.test.ts": 2249, "packages/core/src/agent-store.ts": 2946, "packages/core/src/central-core.ts": 3854, - "packages/core/src/db.ts": 5888, + "packages/core/src/db.ts": 5924, "packages/core/src/mission-store.ts": 4390, "packages/core/src/store.ts": 17358, "packages/core/src/types.ts": 7415, - "packages/dashboard/app/api/legacy.ts": 10821, + "packages/dashboard/app/api/legacy.ts": 10865, "packages/dashboard/app/components/AgentDetailView.tsx": 5400, "packages/dashboard/app/components/AgentsView.tsx": 2147, "packages/dashboard/app/components/ChatView.tsx": 4075, @@ -28,7 +28,7 @@ "packages/dashboard/app/components/MissionManager.tsx": 5042, "packages/dashboard/app/components/ModelOnboardingModal.tsx": 3212, "packages/dashboard/app/components/PlanningModeModal.tsx": 3531, - "packages/dashboard/app/components/QuickEntryBox.tsx": 2229, + "packages/dashboard/app/components/QuickEntryBox.tsx": 2288, "packages/dashboard/app/components/SettingsModal.tsx": 3505, "packages/dashboard/app/components/TaskCard.tsx": 2544, "packages/dashboard/app/components/TaskDetailModal.tsx": 4636, @@ -42,7 +42,7 @@ "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2202, "packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 4679, "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 3002, - "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4707, + "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4850, "packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121, "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2558, "packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 2917,