Split dialog action subscriptions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 10:33:32 -05:00
1 parent e090d9a77b
commit 42e1b1bfa1
25 files changed
+198 -64

No files matched your search

+3 -3
View File
@@ -50,7 +50,7 @@ import { InviteLandingPage } from "./pages/InviteLanding";
import { JoinRequestQueue } from "./pages/JoinRequestQueue";
import { NotFoundPage } from "./pages/NotFound";
import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext";
import { useDialogActions } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox";
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
@@ -139,7 +139,7 @@ function LegacySettingsRedirect() {
function OnboardingRoutePage() {
const { companies } = useCompany();
const { openOnboarding } = useDialog();
const { openOnboarding } = useDialogActions();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const matchedCompany = companyPrefix
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
@@ -231,7 +231,7 @@ function UnprefixedBoardRedirect() {
}
function NoCompaniesStartPage() {
const { openOnboarding } = useDialog();
const { openOnboarding } = useDialogActions();
return (
<div className="mx-auto max-w-xl py-10">
@@ -39,6 +39,7 @@ vi.mock("../context/CompanyContext", () => ({
vi.mock("../context/DialogContext", () => ({
useDialog: () => dialogState,
useDialogActions: () => dialogState,
}));
vi.mock("../context/SidebarContext", () => ({
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
@@ -37,7 +37,7 @@ export function CommandPalette() {
const [query, setQuery] = useState("");
const navigate = useNavigate();
const { selectedCompanyId } = useCompany();
const { openNewIssue, openNewAgent } = useDialog();
const { openNewIssue, openNewAgent } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar();
const searchQuery = query.trim();
+2 -2
View File
@@ -17,7 +17,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { sidebarBadgesApi } from "../api/sidebarBadges";
@@ -125,7 +125,7 @@ function SortableCompanyItem({
export function CompanyRail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openOnboarding } = useDialog();
const { openOnboarding } = useDialogActions();
const navigate = useNavigate();
const location = useLocation();
const isInstanceRoute = location.pathname.startsWith("/instance/");
+1
View File
@@ -48,6 +48,7 @@ vi.mock("../context/CompanyContext", () => ({
vi.mock("../context/DialogContext", () => ({
useDialog: () => dialogState,
useDialogActions: () => dialogState,
}));
vi.mock("@/lib/router", () => ({
+2 -2
View File
@@ -1,7 +1,7 @@
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { Link } from "@/lib/router";
import { executionWorkspacesApi } from "../api/execution-workspaces";
@@ -481,7 +481,7 @@ export function IssuesList({
onUpdateIssue,
}: IssuesListProps) {
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const { openNewIssue } = useDialogActions();
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
+4
View File
@@ -100,6 +100,10 @@ vi.mock("../context/DialogContext", () => ({
openNewIssue: vi.fn(),
openOnboarding: vi.fn(),
}),
useDialogActions: () => ({
openNewIssue: vi.fn(),
openOnboarding: vi.fn(),
}),
}));
vi.mock("../context/PanelContext", () => ({
+2 -2
View File
@@ -18,7 +18,7 @@ import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { SidebarAccountMenu } from "./SidebarAccountMenu";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
@@ -54,7 +54,7 @@ function readRememberedInstanceSettingsPath(): string {
export function Layout() {
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
const { openNewIssue, openOnboarding } = useDialogActions();
const { togglePanelVisible } = usePanel();
const {
companies,
+2 -2
View File
@@ -8,7 +8,7 @@ import {
Inbox,
} from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { cn } from "../lib/utils";
import { useInboxBadge } from "../hooks/useInboxBadge";
@@ -37,7 +37,7 @@ type MobileNavItem = MobileNavLinkItem | MobileNavActionItem;
export function MobileBottomNav({ visible }: MobileBottomNavProps) {
const location = useLocation();
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const { openNewIssue } = useDialogActions();
const inboxBadge = useInboxBadge(selectedCompanyId);
const items = useMemo<MobileNavItem[]>(
+3
View File
@@ -35,6 +35,9 @@ vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
openNewIssue: vi.fn(),
}),
useDialogActions: () => ({
openNewIssue: vi.fn(),
}),
}));
vi.mock("../context/CompanyContext", () => ({
+2 -2
View File
@@ -18,7 +18,7 @@ import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarAgents } from "./SidebarAgents";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
@@ -29,7 +29,7 @@ import { PluginSlotOutlet } from "@/plugins/slots";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function Sidebar() {
const { openNewIssue } = useDialog();
const { openNewIssue } = useDialogActions();
const { selectedCompanyId, selectedCompany } = useCompany();
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: experimentalSettings } = useQuery({
+3
View File
@@ -61,6 +61,9 @@ vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
openNewAgent: mockOpenNewAgent,
}),
useDialogActions: () => ({
openNewAgent: mockOpenNewAgent,
}),
}));
vi.mock("../context/SidebarContext", () => ({
+2 -2
View File
@@ -10,7 +10,7 @@ import {
Plus,
} from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { useToastActions } from "../context/ToastContext";
import { agentsApi } from "../api/agents";
@@ -146,7 +146,7 @@ export function SidebarAgents() {
const [open, setOpen] = useState(true);
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const { openNewAgent } = useDialog();
const { openNewAgent } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar();
const { pushToast } = useToastActions();
const location = useLocation();
+2 -2
View File
@@ -13,7 +13,7 @@ import {
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
@@ -124,7 +124,7 @@ function SortableProjectItem({
export function SidebarProjects() {
const [open, setOpen] = useState(true);
const { selectedCompany, selectedCompanyId } = useCompany();
const { openNewProject } = useDialog();
const { openNewProject } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar();
const location = useLocation();
+58
View File
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { DialogProvider, useDialogActions, useDialogState } from "./DialogContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("DialogContext", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("keeps action-only consumers from rerendering when dialog state changes", () => {
const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
let actionRenderCount = 0;
let stateRenderCount = 0;
function ActionOnlyConsumer() {
actionRenderCount += 1;
const { openNewIssue } = useDialogActions();
return <button onClick={() => openNewIssue()}>Open issue</button>;
}
function StateConsumer() {
stateRenderCount += 1;
const { newIssueOpen } = useDialogState();
return <span>{newIssueOpen ? "open" : "closed"}</span>;
}
act(() => {
root.render(
<DialogProvider>
<ActionOnlyConsumer />
<StateConsumer />
</DialogProvider>,
);
});
expect(actionRenderCount).toBe(1);
expect(stateRenderCount).toBe(1);
const button = host.querySelector("button");
act(() => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).toContain("open");
expect(actionRenderCount).toBe(1);
expect(stateRenderCount).toBe(2);
act(() => root.unmount());
});
});
+90 -29
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
interface NewIssueDefaults {
status?: string;
@@ -48,7 +48,22 @@ interface DialogContextValue {
closeOnboarding: () => void;
}
const DialogContext = createContext<DialogContextValue | null>(null);
type DialogStateValue = Pick<
DialogContextValue,
| "newIssueOpen"
| "newIssueDefaults"
| "newProjectOpen"
| "newGoalOpen"
| "newGoalDefaults"
| "newAgentOpen"
| "onboardingOpen"
| "onboardingOptions"
>;
type DialogActionsValue = Omit<DialogContextValue, keyof DialogStateValue>;
const DialogStateContext = createContext<DialogStateValue | null>(null);
const DialogActionsContext = createContext<DialogActionsValue | null>(null);
export function DialogProvider({ children }: { children: ReactNode }) {
const [newIssueOpen, setNewIssueOpen] = useState(false);
@@ -106,38 +121,84 @@ export function DialogProvider({ children }: { children: ReactNode }) {
setOnboardingOptions({});
}, []);
const stateValue = useMemo<DialogStateValue>(
() => ({
newIssueOpen,
newIssueDefaults,
newProjectOpen,
newGoalOpen,
newGoalDefaults,
newAgentOpen,
onboardingOpen,
onboardingOptions,
}),
[
newIssueOpen,
newIssueDefaults,
newProjectOpen,
newGoalOpen,
newGoalDefaults,
newAgentOpen,
onboardingOpen,
onboardingOptions,
],
);
const actionsValue = useMemo<DialogActionsValue>(
() => ({
openNewIssue,
closeNewIssue,
openNewProject,
closeNewProject,
openNewGoal,
closeNewGoal,
openNewAgent,
closeNewAgent,
openOnboarding,
closeOnboarding,
}),
[
openNewIssue,
closeNewIssue,
openNewProject,
closeNewProject,
openNewGoal,
closeNewGoal,
openNewAgent,
closeNewAgent,
openOnboarding,
closeOnboarding,
],
);
return (
<DialogContext.Provider
value={{
newIssueOpen,
newIssueDefaults,
openNewIssue,
closeNewIssue,
newProjectOpen,
openNewProject,
closeNewProject,
newGoalOpen,
newGoalDefaults,
openNewGoal,
closeNewGoal,
newAgentOpen,
openNewAgent,
closeNewAgent,
onboardingOpen,
onboardingOptions,
openOnboarding,
closeOnboarding,
}}
>
{children}
</DialogContext.Provider>
<DialogActionsContext.Provider value={actionsValue}>
<DialogStateContext.Provider value={stateValue}>
{children}
</DialogStateContext.Provider>
</DialogActionsContext.Provider>
);
}
export function useDialog() {
const ctx = useContext(DialogContext);
export function useDialogActions() {
const ctx = useContext(DialogActionsContext);
if (!ctx) {
throw new Error("useDialog must be used within DialogProvider");
throw new Error("useDialogActions must be used within DialogProvider");
}
return ctx;
}
export function useDialogState() {
const ctx = useContext(DialogStateContext);
if (!ctx) {
throw new Error("useDialogState must be used within DialogProvider");
}
return ctx;
}
export function useDialog() {
return {
...useDialogState(),
...useDialogActions(),
};
}
+2 -2
View File
@@ -19,7 +19,7 @@ import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
import { useToastActions } from "../context/ToastContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { AgentConfigForm } from "../components/AgentConfigForm";
@@ -626,7 +626,7 @@ export function AgentDetail() {
}>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel();
const { openNewIssue } = useDialog();
const { openNewIssue } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
+2 -2
View File
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
@@ -55,7 +55,7 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean
export function Agents() {
const { selectedCompanyId } = useCompany();
const { openNewAgent } = useDialog();
const { openNewAgent } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const location = useLocation();
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { queryKeys } from "../lib/queryKeys";
@@ -36,7 +36,7 @@ export function Companies() {
loading,
error,
} = useCompany();
const { openOnboarding } = useDialog();
const { openOnboarding } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
+2 -2
View File
@@ -9,7 +9,7 @@ import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { buildCompanyUserProfileMap } from "../lib/company-members";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { MetricCard } from "../components/MetricCard";
@@ -36,7 +36,7 @@ function getRecentIssues(issues: Issue[]): Issue[] {
export function Dashboard() {
const { selectedCompanyId, companies } = useCompany();
const { openOnboarding } = useDialog();
const { openOnboarding } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
const seenActivityIdsRef = useRef<Set<string>>(new Set());
+2 -2
View File
@@ -6,7 +6,7 @@ import { projectsApi } from "../api/projects";
import { assetsApi } from "../api/assets";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { GoalProperties } from "../components/GoalProperties";
@@ -49,7 +49,7 @@ export function GoalPropertiesToggleButton({
export function GoalDetail() {
const { goalId } = useParams<{ goalId: string }>();
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openNewGoal } = useDialog();
const { openNewGoal } = useDialogActions();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { goalsApi } from "../api/goals";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { GoalTree } from "../components/GoalTree";
@@ -13,7 +13,7 @@ import { Target, Plus } from "lucide-react";
export function Goals() {
const { selectedCompanyId } = useCompany();
const { openNewGoal } = useDialog();
const { openNewGoal } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
+3
View File
@@ -134,6 +134,9 @@ vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
openNewIssue: vi.fn(),
}),
useDialogActions: () => ({
openNewIssue: vi.fn(),
}),
}));
vi.mock("../context/PanelContext", () => ({
+2 -2
View File
@@ -13,7 +13,7 @@ import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useToastActions } from "../context/ToastContext";
@@ -1025,7 +1025,7 @@ function IssueDetailActivityTab({
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const { openNewIssue } = useDialogActions();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs();
const queryClient = useQueryClient();
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EntityRow } from "../components/EntityRow";
@@ -15,7 +15,7 @@ import { Hexagon, Plus } from "lucide-react";
export function Projects() {
const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialog();
const { openNewProject } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {