feat(FN-5352): merge fusion/fn-5352

This commit is contained in:
gsxdsm
2026-05-21 15:06:25 -07:00
parent 5a76a89071
commit 0f6f0263f5
8 changed files with 687 additions and 2 deletions

View File

@@ -27,6 +27,7 @@ import { CapacityRiskBanner } from "./components/CapacityRiskBanner";
import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner";
import { DbCorruptionBanner } from "./components/DbCorruptionBanner";
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
import MergeAdvanceNotice from "./components/MergeAdvanceNotice";
import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner";
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations";
@@ -1805,6 +1806,9 @@ function AppInner() {
onDismiss={dismissUpdateBanner}
/>
)}
{viewMode === "project" && currentProject && (
<MergeAdvanceNotice projectId={currentProject.id} />
)}
{viewMode === "project" && currentProject && dashboardHealth?.taskIdIntegrity?.status === "anomaly" && dashboardHealth.taskIdIntegrity.recommendedAction && (
<TaskIdIntegrityBanner
report={dashboardHealth.taskIdIntegrity}

View File

@@ -0,0 +1,64 @@
.merge-advance-notice {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
border-inline-start: var(--space-xs) solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
box-shadow: var(--shadow-sm);
}
.merge-advance-notice__content {
color: var(--text);
line-height: 1.4;
}
.merge-advance-notice__actions {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-shrink: 0;
}
.merge-advance-notice__dismiss {
border: 0;
background: none;
color: var(--text-muted);
padding: var(--space-xs);
border-radius: var(--radius-sm);
line-height: 0;
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
}
.merge-advance-notice__dismiss:hover {
color: var(--text);
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
}
.merge-advance-notice__dismiss:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.merge-advance-notice__error {
color: var(--color-error);
}
.merge-advance-notice__hint {
color: var(--text-muted);
}
@media (max-width: 768px) {
.merge-advance-notice {
flex-direction: column;
align-items: stretch;
}
.merge-advance-notice__actions {
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,173 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { X } from "lucide-react";
import { api, ApiRequestError } from "../api";
import { subscribeSse } from "../sse-bus";
import "./MergeAdvanceNotice.css";
interface MergeAdvanceEvent {
taskId: string;
integrationBranch: string;
refName: string;
toSha: string;
fromSha: string | null;
advanceMode: "fast-forward" | "non-fast-forward" | "update-ref" | string;
succeeded: boolean;
advancedAt: string;
userCheckout: {
worktreePath: string;
dirty: boolean;
untrackedCount: number;
} | null;
}
interface MergeAdvanceEventsResponse {
events: MergeAdvanceEvent[];
}
interface PullResponse {
message?: string;
}
interface MergeAdvanceNoticeProps {
projectId?: string;
apiBase?: string;
}
function shortSha(sha: string): string {
return sha.length > 7 ? sha.slice(0, 7) : sha;
}
function dismissedStorageKey(projectId?: string): string {
return `kb:merge-advance-notice-dismissed:${projectId ?? "default"}`;
}
function readDismissedShas(projectId?: string): string[] {
try {
const raw = window.localStorage.getItem(dismissedStorageKey(projectId));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((value): value is string => typeof value === "string");
} catch {
return [];
}
}
function persistDismissedShas(projectId: string | undefined, values: string[]): void {
try {
window.localStorage.setItem(dismissedStorageKey(projectId), JSON.stringify(values.slice(-50)));
} catch {
// ignore storage failures
}
}
export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: MergeAdvanceNoticeProps) {
const [events, setEvents] = useState<MergeAdvanceEvent[]>([]);
const [dismissedShas, setDismissedShas] = useState<string[]>(() => readDismissedShas(projectId));
const [pulling, setPulling] = useState(false);
const [pullError, setPullError] = useState<string | null>(null);
useEffect(() => {
setDismissedShas(readDismissedShas(projectId));
}, [projectId]);
const fetchEvents = useCallback(async () => {
try {
const query = new URLSearchParams({ limit: "5" });
if (projectId) {
query.set("projectId", projectId);
}
const response = await api<MergeAdvanceEventsResponse>(`/tasks/merge-advance-events?${query.toString()}`);
setEvents(Array.isArray(response.events) ? response.events : []);
} catch {
setEvents([]);
}
}, [projectId]);
useEffect(() => {
void fetchEvents();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`${apiBase}/events${query}`, {
events: {
"task:merged": () => {
void fetchEvents();
},
},
});
return () => {
unsubscribe();
};
}, [apiBase, fetchEvents, projectId]);
const notice = useMemo(() => events.find((event) => (
event.succeeded === true
&& event.userCheckout !== null
&& event.userCheckout.worktreePath.trim().length > 0
)), [events]);
if (!notice || dismissedShas.includes(notice.toSha)) {
return null;
}
const localChangesPreserved = notice.userCheckout.dirty || notice.userCheckout.untrackedCount > 0;
const dismiss = () => {
const next = [...dismissedShas.filter((sha) => sha !== notice.toSha), notice.toSha].slice(-50);
setDismissedShas(next);
persistDismissedShas(projectId, next);
};
const handlePull = async () => {
setPulling(true);
setPullError(null);
try {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
await api<PullResponse>(`/git/pull${query}`, {
method: "POST",
body: JSON.stringify({ rebase: false }),
});
dismiss();
} catch (error: unknown) {
if (error instanceof ApiRequestError) {
setPullError(error.message || "Pull failed");
} else if (error instanceof Error && error.message) {
setPullError(error.message);
} else {
setPullError("Pull failed");
}
} finally {
setPulling(false);
}
};
return (
<div className="merge-advance-notice" role="status" aria-live="polite">
<div className="merge-advance-notice__content">
<strong>{notice.integrationBranch} advanced to {shortSha(notice.toSha)}.</strong>{" "}
Your checked-out copy at {notice.userCheckout.worktreePath} is behind.
{localChangesPreserved ? " (local changes preserved)" : ""}
{pullError ? <span className="merge-advance-notice__error"> {pullError}</span> : null}
{pulling ? <span className="merge-advance-notice__hint"> Pulling</span> : null}
</div>
<div className="merge-advance-notice__actions">
{!localChangesPreserved ? (
<button type="button" className="btn btn-sm" disabled={pulling} onClick={handlePull}>
Pull
</button>
) : null}
<button
type="button"
className="merge-advance-notice__dismiss touch-target"
aria-label="Dismiss merge advance notice"
onClick={dismiss}
>
<X aria-hidden="true" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import MergeAdvanceNotice from "../MergeAdvanceNotice";
import { ApiRequestError } from "../../api";
const mocked = vi.hoisted(() => ({
api: vi.fn(),
mergedHandler: undefined as (() => void) | undefined,
}));
vi.mock("../../api", async () => {
const actual = await vi.importActual<typeof import("../../api")>("../../api");
return {
...actual,
api: mocked.api,
};
});
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((_url: string, options: { events?: Record<string, () => void> }) => {
mocked.mergedHandler = options.events?.["task:merged"];
return vi.fn();
}),
}));
function makeEvent(overrides: Partial<Record<string, unknown>> = {}) {
return {
taskId: "FN-1",
integrationBranch: "master",
refName: "refs/heads/master",
toSha: "abcdef123456",
fromSha: "1234567",
advanceMode: "update-ref",
succeeded: true,
advancedAt: "2026-05-21T12:00:00.000Z",
userCheckout: {
worktreePath: "/repo",
dirty: false,
untrackedCount: 0,
},
...overrides,
};
}
describe("MergeAdvanceNotice", () => {
beforeEach(() => {
mocked.api.mockReset();
mocked.mergedHandler = undefined;
window.localStorage.clear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders nothing when api returns no events", async () => {
mocked.api.mockResolvedValueOnce({ events: [] });
const { container } = render(<MergeAdvanceNotice projectId="proj-1" />);
await waitFor(() => expect(mocked.api).toHaveBeenCalledTimes(1));
expect(container.firstChild).toBeNull();
});
it("renders notice with dynamic branch name", async () => {
mocked.api.mockResolvedValueOnce({ events: [makeEvent()] });
render(<MergeAdvanceNotice projectId="proj-1" />);
expect(await screen.findByText(/master advanced to abcdef1\./)).toBeInTheDocument();
expect(screen.getByText(/Your checked-out copy at \/repo is behind\./)).toBeInTheDocument();
});
it.each([
{ dirty: true, untrackedCount: 0 },
{ dirty: false, untrackedCount: 2 },
])("shows local changes preserved and hides pull when dirty markers present", async ({ dirty, untrackedCount }) => {
mocked.api.mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty, untrackedCount } })] });
render(<MergeAdvanceNotice projectId="proj-1" />);
expect(await screen.findByText(/local changes preserved/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pull" })).toBeNull();
});
it("pull posts rebase false and dismisses on success", async () => {
mocked.api
.mockResolvedValueOnce({ events: [makeEvent()] })
.mockResolvedValueOnce({ ok: true });
render(<MergeAdvanceNotice projectId="proj-1" />);
fireEvent.click(await screen.findByRole("button", { name: "Pull" }));
await waitFor(() => expect(mocked.api).toHaveBeenNthCalledWith(2, "/git/pull?projectId=proj-1", {
method: "POST",
body: JSON.stringify({ rebase: false }),
}));
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
});
it("shows inline pull failure and keeps notice visible", async () => {
mocked.api
.mockResolvedValueOnce({ events: [makeEvent()] })
.mockRejectedValueOnce(new ApiRequestError("Merge conflict detected", 409));
render(<MergeAdvanceNotice projectId="proj-1" />);
const pullButton = await screen.findByRole("button", { name: "Pull" });
fireEvent.click(pullButton);
expect(await screen.findByText(/Merge conflict detected/)).toBeInTheDocument();
expect(screen.getByRole("status")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Pull" })).not.toBeDisabled();
});
it("dismisses current sha and stays hidden when same advance is refetched", async () => {
mocked.api
.mockResolvedValueOnce({ events: [makeEvent()] })
.mockResolvedValueOnce({ events: [makeEvent()] });
render(<MergeAdvanceNotice projectId="proj-1" />);
fireEvent.click(await screen.findByRole("button", { name: /dismiss merge advance notice/i }));
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
mocked.mergedHandler?.();
await waitFor(() => expect(mocked.api).toHaveBeenCalledTimes(2));
expect(screen.queryByRole("status")).toBeNull();
});
it("shows fresh advance after previous sha dismissed", async () => {
mocked.api
.mockResolvedValueOnce({ events: [makeEvent({ toSha: "aaaaaaa111" })] })
.mockResolvedValueOnce({ events: [makeEvent({ toSha: "bbbbbbb222" })] });
render(<MergeAdvanceNotice projectId="proj-1" />);
fireEvent.click(await screen.findByRole("button", { name: /dismiss merge advance notice/i }));
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
mocked.mergedHandler?.();
expect(await screen.findByText(/master advanced to bbbbbbb\./)).toBeInTheDocument();
});
it("renders nothing for failed advance or null checkout", async () => {
mocked.api.mockResolvedValueOnce({ events: [makeEvent({ succeeded: false }), makeEvent({ userCheckout: null })] });
const { container } = render(<MergeAdvanceNotice projectId="proj-1" />);
await waitFor(() => expect(mocked.api).toHaveBeenCalledTimes(1));
expect(container.firstChild).toBeNull();
});
});

View File

@@ -0,0 +1,157 @@
// @vitest-environment node
import { describe, it, expect, vi } from "vitest";
import express from "express";
import type { TaskStore, RunAuditEvent } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
function makeEvent(overrides: Partial<RunAuditEvent>): RunAuditEvent {
return {
id: "evt-1",
timestamp: "2026-05-21T00:00:00.000Z",
taskId: "FN-100",
agentId: "agent-1",
runId: "run-1",
domain: "git",
mutationType: "merge:integration-ref-advance",
target: "merge",
metadata: {},
...overrides,
};
}
describe("merge advance events route", () => {
it("returns empty events when audit store is empty", async () => {
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
getRunAuditEvents: vi.fn(() => []),
} as unknown as TaskStore;
const app = express();
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
expect(res.status).toBe(200);
expect(res.body).toEqual({ events: [] });
});
it("hydrates matching worktree state and preserves dynamic branch names", async () => {
const advance = makeEvent({
id: "evt-advance",
mutationType: "merge:integration-ref-advance",
timestamp: "2026-05-21T10:00:00.000Z",
metadata: {
integrationBranch: "master",
refName: "refs/heads/master",
fromSha: "abc1234",
toSha: "def5678",
advanceMode: "update-ref",
succeeded: true,
},
});
const state = makeEvent({
id: "evt-state",
mutationType: "merge:integration-worktree-state",
timestamp: "2026-05-21T09:59:59.000Z",
metadata: {
userCheckout: {
worktreePath: "/repo",
dirty: true,
untrackedCount: 2,
},
},
});
const getRunAuditEvents = vi.fn((filters?: { mutationType?: string }) => {
if (filters?.mutationType === "merge:integration-ref-advance") {
return [advance];
}
if (filters?.mutationType === "merge:integration-worktree-state") {
return [state];
}
return [];
});
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
getRunAuditEvents,
} as unknown as TaskStore;
const app = express();
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
expect(res.status).toBe(200);
expect(res.body).toEqual({
events: [
{
taskId: "FN-100",
integrationBranch: "master",
refName: "refs/heads/master",
toSha: "def5678",
fromSha: "abc1234",
advanceMode: "update-ref",
succeeded: true,
advancedAt: "2026-05-21T10:00:00.000Z",
userCheckout: {
worktreePath: "/repo",
dirty: true,
untrackedCount: 2,
},
},
],
});
});
it("maps succeeded false from metadata", async () => {
const advance = makeEvent({
id: "evt-advance-fail",
metadata: {
integrationBranch: "trunk",
refName: "refs/heads/trunk",
toSha: "deadbeef",
advanceMode: "update-ref",
succeeded: false,
},
});
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
getRunAuditEvents: vi.fn((filters?: { mutationType?: string }) =>
filters?.mutationType === "merge:integration-ref-advance" ? [advance] : []),
} as unknown as TaskStore;
const app = express();
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
expect(res.status).toBe(200);
expect((res.body as { events: Array<{ succeeded: boolean }> }).events[0]?.succeeded).toBe(false);
});
it("defaults limit to 20, clamps max to 100, rejects invalid limit", async () => {
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
getRunAuditEvents: vi.fn(() => []),
} as unknown as TaskStore;
const app = express();
app.use("/api", createApiRoutes(store));
const defaultRes = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
expect(defaultRes.status).toBe(200);
const maxRes = await REQUEST(app, "GET", "/api/tasks/merge-advance-events?limit=999");
expect(maxRes.status).toBe(200);
const badRes = await REQUEST(app, "GET", "/api/tasks/merge-advance-events?limit=abc");
expect(badRes.status).toBe(400);
const getRunAuditEvents = store.getRunAuditEvents as unknown as ReturnType<typeof vi.fn>;
const firstAdvanceCall = getRunAuditEvents.mock.calls.find((call) => call[0]?.mutationType === "merge:integration-ref-advance");
const secondAdvanceCall = [...getRunAuditEvents.mock.calls].reverse().find((call) => call[0]?.mutationType === "merge:integration-ref-advance");
expect(firstAdvanceCall?.[0]?.limit).toBe(20);
expect(secondAdvanceCall?.[0]?.limit).toBe(100);
});
});

View File

@@ -11,6 +11,7 @@ import type {
GithubIssueAction,
DuplicateCandidate,
DuplicateMatch,
RunAuditEvent,
} from "@fusion/core";
import {
COLUMNS,
@@ -49,6 +50,90 @@ const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
interface MergeAdvanceEvent {
taskId: string;
integrationBranch: string;
refName: string;
toSha: string;
fromSha: string | null;
advanceMode: "fast-forward" | "non-fast-forward" | "update-ref" | string;
succeeded: boolean;
advancedAt: string;
userCheckout: {
worktreePath: string;
dirty: boolean;
untrackedCount: number;
} | null;
}
interface MergeAdvanceEventsResponse {
events: MergeAdvanceEvent[];
}
function parseMergeAdvanceLimit(rawLimit: unknown): number {
if (rawLimit === undefined) {
return 20;
}
const parsed = Number.parseInt(String(rawLimit), 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw badRequest("limit must be a positive integer");
}
return Math.min(parsed, 100);
}
function extractUserCheckout(metadata: unknown): MergeAdvanceEvent["userCheckout"] {
if (!metadata || typeof metadata !== "object") {
return null;
}
const raw = (metadata as { userCheckout?: unknown }).userCheckout;
if (!raw || typeof raw !== "object") {
return null;
}
const candidate = raw as { worktreePath?: unknown; dirty?: unknown; untrackedCount?: unknown };
if (typeof candidate.worktreePath !== "string" || candidate.worktreePath.length === 0) {
return null;
}
return {
worktreePath: candidate.worktreePath,
dirty: candidate.dirty === true,
untrackedCount: typeof candidate.untrackedCount === "number" ? candidate.untrackedCount : 0,
};
}
function extractMergeAdvanceEvent(event: RunAuditEvent): Omit<MergeAdvanceEvent, "userCheckout"> | null {
const metadata = event.metadata;
if (!metadata || typeof metadata !== "object") {
console.warn(`[merge-advance-events] dropping run-audit event ${event.id}: missing metadata`);
return null;
}
const candidate = metadata as {
integrationBranch?: unknown;
refName?: unknown;
toSha?: unknown;
fromSha?: unknown;
advanceMode?: unknown;
succeeded?: unknown;
};
if (typeof candidate.integrationBranch !== "string" || candidate.integrationBranch.length === 0 || typeof candidate.toSha !== "string" || candidate.toSha.length === 0) {
console.warn(`[merge-advance-events] dropping run-audit event ${event.id}: missing integrationBranch or toSha`);
return null;
}
if (typeof event.taskId !== "string" || event.taskId.length === 0) {
console.warn(`[merge-advance-events] dropping run-audit event ${event.id}: missing taskId`);
return null;
}
return {
taskId: event.taskId,
integrationBranch: candidate.integrationBranch,
refName: typeof candidate.refName === "string" ? candidate.refName : `refs/heads/${candidate.integrationBranch}`,
toSha: candidate.toSha,
fromSha: typeof candidate.fromSha === "string" ? candidate.fromSha : null,
advanceMode: typeof candidate.advanceMode === "string" ? candidate.advanceMode : "update-ref",
succeeded: candidate.succeeded !== false,
advancedAt: event.timestamp,
};
}
export const __fingerprintCreateLocksForTests = deterministicGuardLocks;
const RESET_TASK_FIELDS = {
@@ -274,7 +359,66 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
} = deps;
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = taskDetailActivityLogLimit;
// List all tasks
// Get recent integration-branch advance events for post-merge notice
router.get("/tasks/merge-advance-events", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const limit = parseMergeAdvanceLimit(req.query.limit);
const getRunAuditEvents = (scopedStore as TaskStore & {
getRunAuditEvents?: (filters: {
taskId?: string;
domain?: "database" | "git" | "filesystem" | "sandbox";
mutationType?: string;
limit?: number;
}) => RunAuditEvent[];
}).getRunAuditEvents;
if (typeof getRunAuditEvents !== "function") {
throw notFound("run-audit unavailable");
}
const advanceEvents = getRunAuditEvents({
domain: "git",
mutationType: "merge:integration-ref-advance",
limit,
});
const events: MergeAdvanceEvent[] = [];
for (const advanceEvent of advanceEvents) {
const extracted = extractMergeAdvanceEvent(advanceEvent);
if (!extracted) {
continue;
}
let userCheckout: MergeAdvanceEvent["userCheckout"] = null;
const stateEvents = getRunAuditEvents({
taskId: extracted.taskId,
domain: "git",
mutationType: "merge:integration-worktree-state",
limit,
});
const matchingState = stateEvents.find((stateEvent) => stateEvent.timestamp <= advanceEvent.timestamp);
if (matchingState) {
userCheckout = extractUserCheckout(matchingState.metadata);
}
events.push({
...extracted,
userCheckout,
});
}
const response: MergeAdvanceEventsResponse = {
events: events.sort((a, b) => Date.parse(b.advancedAt) - Date.parse(a.advancedAt)),
};
res.json(response);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.get("/tasks", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);

View File

@@ -12,7 +12,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",