Phase 3 Section C: error states (T8.15-T8.24)

- T8.15-T8.16: useOnlineStatus hook + OfflineBanner component
  - Track consecutive health check failures, show offline banner after N failures
  - Retry button to manually re-check
- T8.17-T8.18: AgentList empty state ("No agents connected")
- T8.19-T8.20: ChatView welcome/empty state with agent name
- T8.21-T8.22: Send error toast with retry button
- T8.23-T8.24: Console error-free render test

57/57 tests passing across 16 test files.
This commit is contained in:
root
2026-05-22 06:41:47 +00:00
parent cc0b41b553
commit 2f23a69488
11 changed files with 323 additions and 45 deletions

View File

@@ -1 +1 @@
{"version":"4.1.7","results":[[":src/__tests__/smoke.test.tsx",{"duration":25.157009000000016,"failed":false}],[":src/__tests__/tailwind.test.tsx",{"duration":29.542506000000003,"failed":false}],[":src/__tests__/theme.test.tsx",{"duration":39.38911099999996,"failed":false}],[":src/__tests__/api.test.ts",{"duration":38.15992899999992,"failed":false}],[":src/__tests__/AgentList.test.tsx",{"duration":51.33677899999998,"failed":false}],[":src/__tests__/useAgentPolling.test.ts",{"duration":5.701961000000097,"failed":false}],[":src/__tests__/ChatView.test.tsx",{"duration":66.49279899999988,"failed":false}],[":src/__tests__/HealthBar.test.tsx",{"duration":45.92287400000009,"failed":false}],[":src/__tests__/useHealthPolling.test.ts",{"duration":5.440451000000053,"failed":false}],[":src/__tests__/PersonaPanel.test.tsx",{"duration":90.04737499999987,"failed":false}],[":src/__tests__/App.test.tsx",{"duration":63.82890500000008,"failed":false}],[":src/__tests__/SkillsPanel.test.tsx",{"duration":133.36823400000003,"failed":false}],[":src/__tests__/ErrorBoundary.test.tsx",{"duration":28.72702400000003,"failed":false}]]}
{"version":"4.1.7","results":[[":src/__tests__/smoke.test.tsx",{"duration":13.945087999999942,"failed":false}],[":src/__tests__/tailwind.test.tsx",{"duration":29.17875499999991,"failed":false}],[":src/__tests__/theme.test.tsx",{"duration":30.88826199999994,"failed":false}],[":src/__tests__/api.test.ts",{"duration":91.77937500000007,"failed":false}],[":src/__tests__/AgentList.test.tsx",{"duration":46.84238899999991,"failed":false}],[":src/__tests__/useAgentPolling.test.ts",{"duration":7.933536000000004,"failed":false}],[":src/__tests__/ChatView.test.tsx",{"duration":203.7917440000001,"failed":false}],[":src/__tests__/HealthBar.test.tsx",{"duration":33.79023200000006,"failed":false}],[":src/__tests__/useHealthPolling.test.ts",{"duration":6.05030499999998,"failed":false}],[":src/__tests__/PersonaPanel.test.tsx",{"duration":121.4887510000001,"failed":false}],[":src/__tests__/App.test.tsx",{"duration":60.616534,"failed":false}],[":src/__tests__/SkillsPanel.test.tsx",{"duration":138.064118,"failed":false}],[":src/__tests__/ErrorBoundary.test.tsx",{"duration":39.08812399999988,"failed":false}],[":src/__tests__/useOnlineStatus.test.ts",{"duration":7.3779120000000376,"failed":false}],[":src/__tests__/OfflineBanner.test.tsx",{"duration":31.22387200000003,"failed":false}],[":src/__tests__/Console.test.tsx",{"duration":15.374306000000047,"failed":false}]]}

View File

@@ -1,6 +1,9 @@
import { createSignal } from "solid-js";
import { AgentList } from "./components/AgentList";
import { ChatView } from "./components/ChatView";
import { OfflineBanner } from "./components/OfflineBanner";
import { useOnlineStatus } from "./hooks/useOnlineStatus";
import { fetchHealth } from "./api";
import "./index.css";
function HamburgerIcon() {
@@ -24,9 +27,11 @@ function HamburgerIcon() {
export default function App() {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const { offline, check: retryHealth } = useOnlineStatus(fetchHealth);
return (
<div class="flex flex-col md:flex-row h-screen bg-gray-950 text-gray-100">
{offline() && <OfflineBanner onRetry={retryHealth} />}
<button
class="md:hidden p-2 absolute top-2 left-2 z-50"
aria-label="Toggle sidebar"

View File

@@ -14,6 +14,11 @@ describe("AgentList", () => {
expect(getByText("Nyx")).toBeTruthy();
});
it("shows empty state when no agents", () => {
const { getByText } = render(() => <AgentList agents={[]} onSelect={vi.fn()} />);
expect(getByText(/no agents/i)).toBeTruthy();
});
it("shows green dot for online, gray for offline", () => {
const { container } = render(() => <AgentList agents={mockAgents} onSelect={() => {}} />);
const onlineDot = container.querySelector('[data-status="online"]');

View File

@@ -50,8 +50,17 @@ describe("ChatView", () => {
expect(onSend).toHaveBeenCalledWith("a1", "test message");
});
it("shows welcome state when no messages", () => {
const { getByText } = render(() => (
<ChatView messages={[]} onSend={vi.fn()} agentId="a1" agentName="Hermes" />
));
expect(getByText(/start a conversation/i)).toBeTruthy();
expect(getByText(/Hermes/)).toBeTruthy();
});
it("has data-scroll-container on the scrollable area", () => {
const { container } = render(() => <ChatView messages={[]} onSend={() => {}} />);
const messages = [{ id: "m1", role: "user", content: "hi", timestamp: "12:00" }];
const { container } = render(() => <ChatView messages={messages} onSend={() => {}} />);
const scrollContainer = container.querySelector("[data-scroll-container]");
expect(scrollContainer).toBeTruthy();
});
@@ -144,4 +153,41 @@ describe("ChatView", () => {
expect(container.querySelector("[data-streaming]")).toBeNull();
expect(container.querySelector("[data-role='error']")).toBeTruthy();
});
it("shows error toast when message send fails", async () => {
const onSend = vi.fn().mockRejectedValue(new Error("Network failure"));
const { container } = render(() => <ChatView messages={[]} onSend={onSend} agentId="a1" />);
const input = container.querySelector("textarea")!;
fireEvent.input(input, { target: { value: "hello" } });
fireEvent.click(container.querySelector("button[aria-label='Send']")!);
// Wait for the error toast to appear
await vi.waitFor(() => {
expect(container.querySelector("[data-send-error]")).toBeTruthy();
});
});
it("retry button on error toast calls send again", async () => {
const onSend = vi.fn()
.mockRejectedValueOnce(new Error("First fail"))
.mockResolvedValueOnce(undefined);
const { container } = render(() => <ChatView messages={[]} onSend={onSend} agentId="a1" />);
const input = container.querySelector("textarea")!;
fireEvent.input(input, { target: { value: "hello" } });
fireEvent.click(container.querySelector("button[aria-label='Send']")!);
// Wait for toast
await vi.waitFor(() => {
expect(container.querySelector("[data-send-error]")).toBeTruthy();
});
// Click retry
fireEvent.click(container.querySelector("button[aria-label='Retry']")!);
// Should have called onSend twice
expect(onSend).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,29 @@
import { render } from "@solidjs/testing-library";
import { describe, it, expect, vi, afterEach } from "vitest";
import App from "../App";
describe("App console errors", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("renders without console errors", () => {
const errors: string[] = [];
const warn = vi.spyOn(console, "error").mockImplementation((msg: unknown) => {
errors.push(String(msg));
});
render(() => <App />);
// Filter out expected Solid dev warnings
const unexpected = errors.filter(
(e) =>
!e.includes("createRoot") &&
!e.includes("Warning:") &&
!e.includes("Unrecognized value"),
);
expect(unexpected).toHaveLength(0);
warn.mockRestore();
});
});

View File

@@ -0,0 +1,18 @@
import { render, screen, fireEvent } from "@solidjs/testing-library";
import { describe, it, expect, vi } from "vitest";
import { OfflineBanner } from "../components/OfflineBanner";
describe("OfflineBanner", () => {
it("renders the offline message", () => {
const { container } = render(() => <OfflineBanner onRetry={vi.fn()} />);
expect(screen.getByText(/server unreachable/i)).toBeTruthy();
expect(container.querySelector("[data-offline-banner]")).toBeTruthy();
});
it("calls onRetry when retry button clicked", () => {
const onRetry = vi.fn();
render(() => <OfflineBanner onRetry={onRetry} />);
fireEvent.click(screen.getByLabelText("Retry connection"));
expect(onRetry).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@solidjs/testing-library";
import { useOnlineStatus } from "../hooks/useOnlineStatus";
describe("useOnlineStatus", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("reports offline after N consecutive failures", async () => {
const fetchFn = vi.fn().mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useOnlineStatus(fetchFn, 3));
// Initial state
expect(result.offline()).toBe(false);
// First failure
await result.check();
expect(result.offline()).toBe(false);
// Second failure
await result.check();
expect(result.offline()).toBe(false);
// Third failure — threshold reached
await result.check();
expect(result.offline()).toBe(true);
});
it("resets offline on successful fetch", async () => {
const fetchFn = vi.fn()
.mockRejectedValueOnce(new Error("fail"))
.mockRejectedValueOnce(new Error("fail"))
.mockRejectedValueOnce(new Error("fail"))
.mockResolvedValue({ ok: true });
const { result } = renderHook(() => useOnlineStatus(fetchFn, 3));
// Three failures to go offline
await result.check();
await result.check();
await result.check();
expect(result.offline()).toBe(true);
// Now a success resets
await result.check();
expect(result.offline()).toBe(false);
});
it("reset() clears failure count and offline state", async () => {
const fetchFn = vi.fn().mockRejectedValue(new Error("fail"));
const { result } = renderHook(() => useOnlineStatus(fetchFn, 3));
// Force offline
await result.check();
await result.check();
await result.check();
expect(result.offline()).toBe(true);
// Manual reset
result.reset();
expect(result.offline()).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import { For } from "solid-js";
import { For, Show } from "solid-js";
interface Agent {
id: string;
@@ -13,24 +13,31 @@ interface AgentListProps {
export function AgentList(props: AgentListProps) {
return (
<ul class="space-y-1">
<For each={props.agents}>
{(agent) => (
<li
data-agent-id={agent.id}
class="flex items-center gap-2 px-3 py-2 rounded cursor-pointer hover:bg-gray-800 transition-colors"
onClick={() => props.onSelect(agent.id)}
>
<span
data-status={agent.status}
class={`inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ${
agent.status === "online" ? "bg-green-500" : "bg-gray-500"
}`}
/>
<span class="text-sm text-gray-200">{agent.name}</span>
</li>
)}
</For>
</ul>
<Show
when={props.agents.length > 0}
fallback={
<div class="text-gray-500 text-center py-8">No agents connected</div>
}
>
<ul class="space-y-1">
<For each={props.agents}>
{(agent) => (
<li
data-agent-id={agent.id}
class="flex items-center gap-2 px-3 py-2 rounded cursor-pointer hover:bg-gray-800 transition-colors"
onClick={() => props.onSelect(agent.id)}
>
<span
data-status={agent.status}
class={`inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ${
agent.status === "online" ? "bg-green-500" : "bg-gray-500"
}`}
/>
<span class="text-sm text-gray-200">{agent.name}</span>
</li>
)}
</For>
</ul>
</Show>
);
}

View File

@@ -1,4 +1,4 @@
import { For, createSignal, createEffect, Show, on } from "solid-js";
import { For, createSignal, createEffect, Show } from "solid-js";
interface Message {
id: string;
@@ -10,8 +10,9 @@ interface Message {
interface ChatViewProps {
messages: Message[];
onSend: (agentId: string, content: string) => void;
onSend: (agentId: string, content: string) => void | Promise<void>;
agentId?: string;
agentName?: string;
}
export function ChatView(props: ChatViewProps) {
@@ -23,13 +24,32 @@ export function ChatView(props: ChatViewProps) {
};
const [inputValue, setInputValue] = createSignal("");
const [sendError, setSendError] = createSignal<string | null>(null);
const [pendingContent, setPendingContent] = createSignal("");
let scrollContainerRef!: HTMLDivElement;
const handleSend = () => {
const handleSend = async () => {
const content = inputValue().trim();
if (!content) return;
props.onSend(props.agentId || "", content);
setInputValue("");
const agentId = props.agentId || "";
try {
setPendingContent(content);
setInputValue("");
await props.onSend(agentId, content);
setSendError(null);
setPendingContent("");
} catch (e) {
setSendError("Failed to send message. Retry?");
setPendingContent(content);
}
};
const handleRetry = () => {
setSendError(null);
const content = pendingContent();
if (!content) return;
const agentId = props.agentId || "";
props.onSend(agentId, content);
};
const handleKeyDown = (e: KeyboardEvent) => {
@@ -55,27 +75,58 @@ export function ChatView(props: ChatViewProps) {
data-scroll-container
class="flex-1 overflow-y-auto p-4 space-y-3"
>
<For each={props.messages}>
{(msg) => (
<div
class={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
data-role={msg.role}
class={`max-w-[80%] rounded-lg px-4 py-2 ${
roleColors[msg.role] || roleColors.agent
}`}
>
{msg.content}
<Show when={msg.streaming}>
<span data-streaming class="inline-block w-2 h-4 ml-1 bg-indigo-400 animate-pulse rounded-sm" />
</Show>
<div class="text-xs mt-1 opacity-60">{msg.timestamp}</div>
<Show
when={props.messages.length > 0}
fallback={
<div class="flex-1 flex items-center justify-center text-gray-500" style="min-height: 200px">
<div class="text-center">
<p class="text-lg">Start a conversation with {props.agentName || "agent"}</p>
<p class="text-sm mt-2">Type a message below to begin</p>
</div>
</div>
)}
</For>
}
>
<For each={props.messages}>
{(msg) => (
<div
class={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
data-role={msg.role}
class={`max-w-[80%] rounded-lg px-4 py-2 ${
roleColors[msg.role] || roleColors.agent
}`}
>
{msg.content}
<Show when={msg.streaming}>
<span data-streaming class="inline-block w-2 h-4 ml-1 bg-indigo-400 animate-pulse rounded-sm" />
</Show>
<div class="text-xs mt-1 opacity-60">{msg.timestamp}</div>
</div>
</div>
)}
</For>
</Show>
</div>
<Show
when={sendError()}
>
<div
data-send-error
class="bg-red-900/50 border border-red-500/50 text-red-200 p-3 rounded-md mx-4 mb-2 flex justify-between items-center"
>
<span>{sendError()}</span>
<button
aria-label="Retry"
onClick={handleRetry}
class="underline"
>
Retry
</button>
</div>
</Show>
<div class="border-t border-gray-700 p-3">
<div class="flex gap-2">
<textarea

View File

@@ -0,0 +1,21 @@
interface OfflineBannerProps {
onRetry: () => void;
}
export function OfflineBanner(props: OfflineBannerProps) {
return (
<div
data-offline-banner
class="bg-amber-900/50 border-b border-amber-500/50 text-amber-200 px-4 py-2 flex justify-between items-center"
>
<span> API server unreachable retrying...</span>
<button
aria-label="Retry connection"
onClick={props.onRetry}
class="underline text-amber-100 hover:text-amber-50"
>
Retry
</button>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { createSignal } from "solid-js";
export function useOnlineStatus(fetchHealth: () => Promise<any>, maxFailures = 3) {
const [offline, setOffline] = createSignal(false);
const [failureCount, setFailureCount] = createSignal(0);
const check = async () => {
try {
await fetchHealth();
setFailureCount(0);
setOffline(false);
} catch {
const newCount = failureCount() + 1;
setFailureCount(newCount);
if (newCount >= maxFailures) {
setOffline(true);
}
}
};
const reset = () => {
setFailureCount(0);
setOffline(false);
};
return { offline, check, reset };
}