fix(dashboard): keep Agents title row on a single line on mobile

Switch the .agents-view-title flex behavior from wrap to nowrap and apply
white-space: nowrap on the title and its h2, so the Bot icon and "Agents"
label stay on one row and the view-toggle / primary actions push to the
right edge. Drop the dedicated 32px icon-only width override that came from
the previous wrapping layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-24 23:53:57 -07:00
committed by gsxdsm
parent 07d7bac165
commit 7fc3334934
11 changed files with 1782 additions and 456 deletions

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import { DashboardApp } from "../app.js";
import { DashboardTUI } from "../controller.js";
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues } from "../state.js";
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues, TaskDetailData } from "../state.js";
function newController(): DashboardTUI {
return new DashboardTUI();
@@ -32,11 +32,13 @@ function makeInteractiveData(opts: {
detail?: AgentDetailItem | null;
settings?: SettingsValues;
models?: ModelItem[];
taskDetail?: TaskDetailData | null;
} = {}) {
const projects = opts.projects ?? [];
const tasks = opts.tasks ?? [];
const agents = opts.agents ?? [];
const detail = opts.detail ?? null;
const taskDetail = opts.taskDetail ?? null;
const settings: SettingsValues = opts.settings ?? {
maxConcurrent: 1,
maxWorktrees: 2,
@@ -91,6 +93,21 @@ function makeInteractiveData(opts: {
push: async () => ({ success: true, output: "" }),
fetch: async () => ({ success: true, output: "" }),
},
tasks: {
getTaskDetail: async () => taskDetail,
subscribeTaskEvents: () => () => {},
},
files: {
listDirectory: async () => [],
readFile: async () => ({
content: null,
isBinary: false,
tooLarge: false,
size: 0,
modifiedAt: new Date(0).toISOString(),
lineCount: 0,
}),
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,4 +22,10 @@ export type {
GitCommitDetail,
GitBranch,
GitWorktree,
FileEntry,
FileReadResult,
TaskStep,
TaskLogEntry,
TaskDetailData,
TaskEvent,
} from "./state.js";

View File

@@ -8,7 +8,7 @@ export type SectionId = "logs" | "system" | "utilities" | "stats" | "settings";
export type AppMode = "status" | "interactive";
export type InteractiveView = "board" | "agents" | "settings" | "git";
export type InteractiveView = "board" | "agents" | "settings" | "git" | "files";
export interface SystemInfo {
host: string;
@@ -124,6 +124,60 @@ export interface ModelItem {
contextWindow: number;
}
// ── File explorer types ───────────────────────────────────────────────────────
export interface FileEntry {
name: string;
path: string; // relative to project root
isDirectory: boolean;
size: number; // bytes; 0 for dirs
modifiedAt: string; // ISO
}
export interface FileReadResult {
content: string | null; // null if binary or too-large
isBinary: boolean;
tooLarge: boolean;
size: number;
modifiedAt: string;
lineCount: number;
}
// ── Task detail + streaming types ────────────────────────────────────────────
export interface TaskStep {
index: number;
name: string;
status: "pending" | "running" | "done" | "skipped" | "failed";
startedAt?: string;
endedAt?: string;
}
export interface TaskLogEntry {
timestamp: string; // ISO-8601
level: "info" | "warn" | "error" | "debug";
text: string;
source?: string; // e.g. "executor" / "agent" / step name
}
export interface TaskDetailData {
id: string;
title?: string;
description: string;
column: string;
agentState?: string;
branch?: string;
worktree?: string;
currentStepIndex?: number;
steps: TaskStep[];
recentLogs: TaskLogEntry[]; // last ~200 entries on initial load
}
export type TaskEvent =
| { kind: "step:updated"; step: TaskStep }
| { kind: "log:appended"; entry: TaskLogEntry }
| { kind: "task:updated"; task: TaskDetailData };
// ── Git view types ────────────────────────────────────────────────────────────
export interface GitStatus {
@@ -188,6 +242,21 @@ export interface InteractiveData {
push: (projectPath: string) => Promise<{ success: boolean; output: string }>;
fetch: (projectPath: string) => Promise<{ success: boolean; output: string }>;
};
files: {
listDirectory: (projectPath: string, relativePath: string) => Promise<FileEntry[]>;
readFile: (projectPath: string, relativePath: string) => Promise<FileReadResult>;
};
tasks: {
// Initial fetch when the detail screen mounts — includes steps + recent logs.
getTaskDetail: (projectPath: string, taskId: string) => Promise<TaskDetailData | null>;
// Subscribe to live step-change and log-append events for a single task.
// Returns an unsubscribe function.
subscribeTaskEvents: (
projectPath: string,
taskId: string,
handler: (event: TaskEvent) => void,
) => () => void;
};
}
// ── Dashboard state (mutable, shared between controller and App) ───────────────

View File

@@ -1,8 +1,8 @@
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { join, resolve as pathResolve } from "node:path";
import { execFile as execFileCb } from "node:child_process";
import { promisify } from "node:util";
import { stat } from "node:fs/promises";
import { stat, readdir, readFile as fsReadFile } from "node:fs/promises";
import {
TaskStore,
AutomationStore,
@@ -45,7 +45,7 @@ import {
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree } from "./dashboard-tui/index.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
// Re-export for backward compatibility with tests
export { promptForPort };
@@ -496,6 +496,84 @@ async function buildGitWorktrees(projectPath: string): Promise<GitWorktree[]> {
return worktrees;
}
// Standard denylist applied to both listing and reads (defence-in-depth).
const FILES_DENYLIST = new Set(["node_modules", ".git", "dist", ".next", "target", "build"]);
const FILE_SIZE_LIMIT = 1024 * 1024; // 1 MB
const BINARY_CHECK_BYTES = 8 * 1024; // 8 KB
const MAX_PREVIEW_LINES = 2000;
function guardRelativePath(projectPath: string, relativePath: string): string {
// Prevent path traversal: the resolved absolute path must start with projectPath.
const resolved = pathResolve(projectPath, relativePath);
const base = projectPath.endsWith("/") ? projectPath : projectPath + "/";
if (resolved !== projectPath && !resolved.startsWith(base)) {
throw new Error(`Path traversal denied: ${relativePath}`);
}
return resolved;
}
async function buildFileListDirectory(projectPath: string, relativePath: string): Promise<FileEntry[]> {
const absDir = guardRelativePath(projectPath, relativePath);
const dirents = await readdir(absDir, { withFileTypes: true });
const entries: FileEntry[] = [];
for (const d of dirents) {
if (FILES_DENYLIST.has(d.name)) continue;
const entryRelPath = relativePath ? `${relativePath}/${d.name}` : d.name;
let size = 0;
let modifiedAt = new Date(0).toISOString();
try {
const s = await stat(join(absDir, d.name));
size = d.isDirectory() ? 0 : s.size;
modifiedAt = s.mtime.toISOString();
} catch {
// Silently skip entries we can't stat (permission errors, broken symlinks)
}
entries.push({
name: d.name,
path: entryRelPath,
isDirectory: d.isDirectory(),
size,
modifiedAt,
});
}
// Sort: directories first, alphabetical within each group
entries.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return entries;
}
async function buildFileReadFile(projectPath: string, relativePath: string): Promise<FileReadResult> {
const absFile = guardRelativePath(projectPath, relativePath);
const s = await stat(absFile);
const modifiedAt = s.mtime.toISOString();
const size = s.size;
if (size > FILE_SIZE_LIMIT) {
return { content: null, isBinary: false, tooLarge: true, size, modifiedAt, lineCount: 0 };
}
const buf = await fsReadFile(absFile);
// Binary heuristic: look for null byte in the first BINARY_CHECK_BYTES
const checkLen = Math.min(buf.length, BINARY_CHECK_BYTES);
for (let i = 0; i < checkLen; i++) {
if (buf[i] === 0) {
return { content: null, isBinary: true, tooLarge: false, size, modifiedAt, lineCount: 0 };
}
}
const text = buf.toString("utf8");
const lines = text.split("\n");
const lineCount = lines.length;
const content = lineCount > MAX_PREVIEW_LINES
? lines.slice(0, MAX_PREVIEW_LINES).join("\n")
: text;
return { content, isBinary: false, tooLarge: false, size, modifiedAt, lineCount };
}
async function resolveRuntimeProjectPath(): Promise<string> {
try {
return (await resolveProject(undefined)).projectPath;
@@ -702,8 +780,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
async function getProjectStore(projectPath: string): Promise<TaskStore> {
const cached = projectStores.get(projectPath);
if (cached) return cached;
const projectStore = projectPath === cwd ? store : new TaskStore(projectPath);
if (projectPath !== cwd) await projectStore.init();
let projectStore: TaskStore;
if (projectPath === cwd) {
if (!store) throw new Error("cwd TaskStore not yet initialized");
projectStore = store;
} else {
projectStore = new TaskStore(projectPath);
await projectStore.init();
}
projectStores.set(projectPath, projectStore);
return projectStore;
}
@@ -1859,6 +1943,102 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
},
},
files: {
listDirectory: (projectPath: string, relativePath: string) =>
buildFileListDirectory(projectPath, relativePath),
readFile: (projectPath: string, relativePath: string) =>
buildFileReadFile(projectPath, relativePath),
},
tasks: {
getTaskDetail: async (projectPath: string, taskId: string): Promise<TaskDetailData | null> => {
try {
const projectStore = await getProjectStore(projectPath);
// getTask loads full data: steps, log, branch, worktree.
const t = await projectStore.getTask(taskId);
// Map core StepStatus ("in-progress") → TUI status ("running").
const steps: TUITaskStep[] = t.steps.map((s, idx) => ({
index: idx,
name: s.name,
status: s.status === "in-progress" ? "running" : (s.status as TUITaskStep["status"]),
}));
// Map task activity log entries (action + outcome text) → TUI log entries.
// The core log has no severity level, so we emit them all as "info".
const recentLogs: TUITaskLogEntry[] = t.log.slice(-200).map((entry) => ({
timestamp: entry.timestamp,
level: "info" as const,
text: entry.outcome ? `${entry.action}${entry.outcome}` : entry.action,
source: entry.runContext?.agentId ? "agent" : "executor",
}));
return {
id: t.id,
title: t.title,
description: t.description ?? "",
column: t.column,
agentState: (t as { agentState?: string }).agentState,
branch: t.branch,
worktree: t.worktree,
currentStepIndex: t.currentStep,
steps,
recentLogs,
};
} catch {
// Task not found (deleted/archived between selection and fetch).
return null;
}
},
subscribeTaskEvents: (
projectPath: string,
taskId: string,
handler: (event: TaskEvent) => void,
): (() => void) => {
// Subscribe to the project store's task:updated event; filter by taskId.
// Steps + log both land via task:updated whenever the engine writes a task.
let projectStorePromise: Promise<typeof store> | null = null;
// Track the last log length so we only emit new entries as log:appended.
let lastLogLength = 0;
const listener = (task: { id: string; steps: Array<{ name: string; status: string }>; currentStep: number; log: Array<{ timestamp: string; action: string; outcome?: string; runContext?: { agentId?: string } }>; column: string; title?: string; description: string; branch?: string; worktree?: string }) => {
if (task.id !== taskId) return;
// Emit step:updated events for any step whose status differs.
task.steps.forEach((s, idx) => {
const status = s.status === "in-progress" ? "running" : s.status as TUITaskStep["status"];
handler({
kind: "step:updated",
step: { index: idx, name: s.name, status },
});
});
// Emit log:appended for each new log entry appended since last event.
const newEntries = task.log.slice(lastLogLength);
lastLogLength = task.log.length;
for (const entry of newEntries) {
handler({
kind: "log:appended",
entry: {
timestamp: entry.timestamp,
level: "info" as const,
text: entry.outcome ? `${entry.action}${entry.outcome}` : entry.action,
source: entry.runContext?.agentId ? "agent" : "executor",
},
});
}
};
// Resolve the project store and attach the listener asynchronously.
projectStorePromise = getProjectStore(projectPath).then((ps) => {
ps.on("task:updated", listener as Parameters<typeof ps.on>[1]);
return ps;
}).catch(() => null as unknown as typeof store);
return () => {
// Detach the listener once the store resolves (or immediately if already resolved).
void projectStorePromise?.then((ps) => {
if (ps) ps.off("task:updated", listener as Parameters<typeof ps.off>[1]);
});
};
},
},
});
}

View File

@@ -47,6 +47,8 @@ import type { AiSessionSummary } from "./api";
import { fetchUnreadCount, reportDashboardPerf } from "./api";
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
import { subscribeSse } from "./sse-bus";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
// ChatView's CSS is imported eagerly so the styles bundle into the main
// CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS
@@ -286,6 +288,7 @@ function AppInner() {
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
const [quickChatOpen, setQuickChatOpen] = useState(false);
const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false);
const [setupWarningDismissed, setSetupWarningDismissed] = useState(
() => getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true",
);
@@ -296,6 +299,17 @@ function AppInner() {
);
}, [currentProject?.id]);
useEffect(() => {
const handleDaemonAuthFailure = () => {
setAuthTokenRecoveryOpen(true);
};
window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure);
return () => {
window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure);
};
}, []);
const handleDismissSetupWarning = useCallback(() => {
setScopedItem(SETUP_WARNING_DISMISSED_KEY, "true", currentProject?.id);
setSetupWarningDismissed(true);
@@ -916,6 +930,7 @@ function AppInner() {
modalManager.openModelOnboarding();
}}
/>
<AuthTokenRecoveryDialog open={authTokenRecoveryOpen} />
</>
);
}

View File

@@ -65,3 +65,106 @@ describe("auth helpers", () => {
expect(withTokenHeader(original)).toBe(original);
});
});
describe("installAuthFetch", () => {
const originalFetch = window.fetch;
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState({}, "", "/");
window.fetch = originalFetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test cleanup for sentinel
delete (window as any).__fnAuthFetchInstalled;
});
it("injects Authorization only for same-origin /api requests", async () => {
window.localStorage.setItem("fn.authToken", "daemon-token");
const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
return new Response(JSON.stringify({ auth: headers.get("Authorization") }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
window.fetch = fetchSpy as unknown as typeof window.fetch;
const { installAuthFetch } = await loadAuthModule();
installAuthFetch();
const apiResponse = await fetch("/api/tasks");
expect(await apiResponse.json()).toEqual({ auth: "Bearer daemon-token" });
await fetch("https://example.com/api/tasks");
const crossOriginHeaders = new Headers(fetchSpy.mock.calls[1]?.[1]?.headers);
expect(crossOriginHeaders.get("Authorization")).toBeNull();
});
it("fires the daemon auth recovery signal only for daemon auth 401 payloads and dedupes repeats", async () => {
window.localStorage.setItem("fn.authToken", "stale-token");
window.fetch = vi.fn(async () => {
return new Response(
JSON.stringify({ error: "Unauthorized", message: "Valid bearer token required" }),
{
status: 401,
headers: { "content-type": "application/json" },
},
);
}) as unknown as typeof window.fetch;
const { installAuthFetch, AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } = await loadAuthModule();
installAuthFetch();
const eventHandler = vi.fn();
window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, eventHandler);
const first = await fetch("/api/tasks");
expect(await first.json()).toEqual({ error: "Unauthorized", message: "Valid bearer token required" });
await vi.waitFor(() => {
expect(eventHandler).toHaveBeenCalledTimes(1);
});
await fetch("/api/tasks?next=1");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(eventHandler).toHaveBeenCalledTimes(1);
window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, eventHandler);
});
it("does not fire the recovery signal for unrelated 401 payloads", async () => {
window.localStorage.setItem("fn.authToken", "stale-token");
window.fetch = vi.fn(async () => {
return new Response(JSON.stringify({ error: "Unauthorized", message: "Project auth required" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof window.fetch;
const { installAuthFetch, AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } = await loadAuthModule();
installAuthFetch();
const eventHandler = vi.fn();
window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, eventHandler);
const response = await fetch("/api/tasks");
expect(await response.json()).toEqual({ error: "Unauthorized", message: "Project auth required" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(eventHandler).not.toHaveBeenCalled();
window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, eventHandler);
});
it("is idempotent and only installs one fetch wrapper", async () => {
window.localStorage.setItem("fn.authToken", "daemon-token");
const fetchSpy = vi.fn(async () => new Response("ok", { status: 200 }));
window.fetch = fetchSpy as unknown as typeof window.fetch;
const { installAuthFetch } = await loadAuthModule();
installAuthFetch();
installAuthFetch();
await fetch("/api/tasks");
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(new Headers(fetchSpy.mock.calls[0]?.[1]?.headers).get("Authorization")).toBe("Bearer daemon-token");
});
});

View File

@@ -31,6 +31,18 @@ export const QUERY_TOKEN_PARAM = "fn_token";
let cachedToken: string | undefined;
let captureAttempted = false;
let daemonAuthFailureSignaled = false;
/**
* Browser event fired when the dashboard API returns the daemon-auth 401 payload,
* indicating the current browser token is missing/invalid and user recovery is required.
*/
export const AUTH_TOKEN_RECOVERY_REQUIRED_EVENT = "fn:auth-token-recovery-required";
interface DaemonUnauthorizedPayload {
error?: unknown;
message?: unknown;
}
function readStoredToken(): string | undefined {
try {
@@ -100,12 +112,14 @@ export function getAuthToken(): string | undefined {
/** Persist a token for future dashboard API requests in this browser session. */
export function setAuthToken(token: string): void {
cachedToken = token;
daemonAuthFailureSignaled = false;
writeStoredToken(token);
}
/** Clear the stored token (e.g., on a 401 response). */
export function clearAuthToken(): void {
cachedToken = undefined;
daemonAuthFailureSignaled = false;
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
@@ -186,6 +200,45 @@ export function withTokenHeader(init?: HeadersInit): HeadersInit | undefined {
return headers;
}
function isDaemonAuthUnauthorizedPayload(payload: unknown): boolean {
if (!payload || typeof payload !== "object") {
return false;
}
const candidate = payload as DaemonUnauthorizedPayload;
return candidate.error === "Unauthorized" && candidate.message === "Valid bearer token required";
}
function emitDaemonAuthRecoverySignal(): void {
if (typeof window === "undefined" || daemonAuthFailureSignaled) {
return;
}
daemonAuthFailureSignaled = true;
window.dispatchEvent(new CustomEvent(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT));
}
async function detectDaemonAuthFailure(response: Response): Promise<void> {
if (response.status !== 401) {
return;
}
try {
const responseClone = response.clone();
const contentType = responseClone.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return;
}
const payload = await responseClone.json();
if (isDaemonAuthUnauthorizedPayload(payload)) {
emitDaemonAuthRecoverySignal();
}
} catch {
// If body parsing fails, leave response untouched for callers.
}
}
/**
* Monkey-patch `window.fetch` once so every same-origin `/api/*` request gets
* a bearer token. This covers direct `fetch()` callers that don't route
@@ -205,9 +258,6 @@ export function installAuthFetch(): void {
const originalFetch = window.fetch.bind(window);
window.fetch = function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const token = getAuthToken();
if (!token) {
return originalFetch(input, init);
}
const urlString = typeof input === "string"
? input
@@ -215,7 +265,7 @@ export function installAuthFetch(): void {
? input.toString()
: input.url;
// Only attach the token for same-origin /api/* requests.
// Only attach the token and watch for daemon auth failures on same-origin /api/* requests.
const isApiCall = (() => {
try {
const resolved = new URL(urlString, window.location.origin);
@@ -231,9 +281,13 @@ export function installAuthFetch(): void {
}
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (!headers.has("Authorization")) {
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
return originalFetch(input, { ...init, headers });
return originalFetch(input, { ...init, headers }).then((response) => {
void detectDaemonAuthFailure(response);
return response;
});
};
}

View File

@@ -874,13 +874,16 @@
}
.agents-view-title {
flex: 0 1 auto;
flex: 0 0 auto;
gap: var(--space-xs);
flex-wrap: wrap;
white-space: nowrap;
min-width: 0;
}
.agents-view-title h2 {
font-size: 16px;
white-space: nowrap;
}
/* Layout: title (Bot) on the left, view-toggle right after it, then the
@@ -947,13 +950,10 @@
min-width: 28px;
}
/* Title's Bot icon: 32×32 footprint so it sits at the same outer height
as the view-toggle pill. The icon itself is 24px (set in JSX), giving
it more visual weight than the surrounding 16px button icons. */
/* Title row: keep Bot icon + "Agents" text on a single line so the
view-toggle and primary actions get pushed to the right edge. */
.agents-view-title {
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
}
}

View File

@@ -864,10 +864,6 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
projectId={projectId}
/>
{/* Stats and live agents above the collection */}
<AgentMetricsBar stats={stats} />
<ActiveAgentsPanel agents={activeAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
{/* Agent Collection */}
{agentView === "tree" ? (
<div className="agent-tree__view">
@@ -1267,6 +1263,10 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
</div>
)}
{/* Secondary sections after the main collection */}
<AgentMetricsBar stats={stats} />
<ActiveAgentsPanel agents={activeAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
</div>
{/* Agent Detail Modal */}

View File

@@ -149,21 +149,6 @@ vi.mock("../../components/CustomModelDropdown", () => ({
),
}));
// Mock CustomModelDropdown for onboarding modal tests
vi.mock("../../components/CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
<select
data-testid="mock-model-dropdown"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder ?? "Select…"}</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
),
}));
// Mock state holders for dynamic mocking
const mockRefreshProjects = vi.fn(async () => {});
@@ -230,6 +215,7 @@ vi.mock("../../hooks/useNodes", () => ({
}));
import { App } from "../../App";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels } from "../../api";
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
@@ -2428,3 +2414,33 @@ describe("App onboarding reopen", () => {
expect((dropdown as HTMLSelectElement).value).toBe("anthropic/claude-sonnet-4-5");
});
});
describe("App auth token recovery dialog", () => {
it("opens as a non-dismissable blocking dialog when daemon auth recovery is required", async () => {
render(<App />);
await waitForAppShell();
expect(screen.queryByRole("dialog", { name: "Authentication token required" })).toBeNull();
act(() => {
window.dispatchEvent(new CustomEvent(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT));
});
const dialog = await screen.findByRole("dialog", { name: "Authentication token required" });
expect(dialog).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /close/i })).toBeNull();
const overlay = dialog.closest(".auth-token-recovery-overlay");
expect(overlay).toBeTruthy();
if (!overlay) {
throw new Error("Expected auth token recovery overlay to be present");
}
fireEvent.keyDown(overlay, { key: "Escape" });
fireEvent.click(overlay);
expect(screen.getByRole("dialog", { name: "Authentication token required" })).toBeInTheDocument();
});
});