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:
@@ -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
@@ -22,4 +22,10 @@ export type {
|
||||
GitCommitDetail,
|
||||
GitBranch,
|
||||
GitWorktree,
|
||||
FileEntry,
|
||||
FileReadResult,
|
||||
TaskStep,
|
||||
TaskLogEntry,
|
||||
TaskDetailData,
|
||||
TaskEvent,
|
||||
} from "./state.js";
|
||||
|
||||
@@ -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) ───────────────
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user