Compare commits

..

3 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 83ea515962 perf: optimize Tasker boot and kanban load 2026-05-15 19:18:25 +03:00
DCCONSTRUCTIONS 44d2c5dd27 CHORE - NAS DEPLOY: stabilize legacy Tasker rebuild flow 2026-05-15 14:25:36 +03:00
DCCONSTRUCTIONS 6962642614 FEAT - TASKER: support Codex agent labels and profiles 2026-05-15 14:25:36 +03:00
33 changed files with 1201 additions and 275 deletions

View File

@ -1,7 +1,8 @@
import re
from html import escape
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db import IntegrityError, transaction
from django.http import JsonResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
@ -30,6 +31,11 @@ NODEDC_STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks"
AGENT_EMAIL_DOMAIN = "agents.nodedc.local"
AGENT_BOT_TYPE = "nodedc_codex_agent"
ALLOWED_PRIORITIES = ["none", "low", "medium", "high", "urgent"]
DEFAULT_AGENT_LABEL_COLOR = "#4E5355"
MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000
MARKDOWN_HEADING_PATTERN = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*$")
HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
DATA_IMAGE_PATTERN = re.compile(r"^data:image/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$")
def unauthorized_response():
@ -169,24 +175,80 @@ def get_agent_identity(request):
}
def ensure_agent_actor(request, workspace, project=None):
def get_agent_payload_metadata(payload):
if not isinstance(payload, dict):
return {}
metadata = payload.get("_agent")
return metadata if isinstance(metadata, dict) else {}
def normalize_agent_display_name(value, fallback):
if isinstance(value, str):
display_name = value.strip()
if display_name:
return display_name[:120]
return fallback
def normalize_agent_avatar_url(value):
if not isinstance(value, str):
return ""
avatar_url = value.strip()
if not avatar_url or len(avatar_url) > MAX_AGENT_AVATAR_URL_LENGTH:
return ""
if avatar_url.startswith("data:image/"):
return avatar_url if DATA_IMAGE_PATTERN.match(avatar_url) else ""
if avatar_url.startswith("http://") or avatar_url.startswith("https://"):
return avatar_url
return ""
def get_agent_profile(request, payload=None):
identity = get_agent_identity(request)
if identity is None:
return None
short_id = identity["agent_id"].replace("-", "")[:12]
fallback_display_name = f"Codex Agent {short_id}"
metadata = get_agent_payload_metadata(payload)
return {
"display_name": normalize_agent_display_name(metadata.get("display_name"), fallback_display_name),
"avatar_url": normalize_agent_avatar_url(metadata.get("avatar_url")),
"short_id": short_id,
}
def ensure_agent_actor(request, workspace, project=None, payload=None):
identity = get_agent_identity(request)
if identity is None:
return None
agent_id = identity["agent_id"]
short_id = agent_id.replace("-", "")[:12]
profile = get_agent_profile(request, payload)
if profile is None:
return None
short_id = profile["short_id"]
email = f"agent+{agent_id}@{AGENT_EMAIL_DOMAIN}"
username = f"nodedc_agent_{short_id}"
display_name = f"Codex Agent {short_id}"
display_name = profile["display_name"]
avatar_url = profile["avatar_url"]
user, _ = User.objects.get_or_create(
email=email,
defaults={
"username": username,
"display_name": display_name,
"first_name": "Codex",
"last_name": f"Agent {short_id}",
"first_name": display_name,
"last_name": "",
"avatar": avatar_url,
"is_bot": True,
"bot_type": AGENT_BOT_TYPE,
"is_active": True,
@ -206,6 +268,18 @@ def ensure_agent_actor(request, workspace, project=None):
if user.display_name != display_name:
user.display_name = display_name
update_fields.append("display_name")
if user.first_name != display_name:
user.first_name = display_name
update_fields.append("first_name")
if user.last_name:
user.last_name = ""
update_fields.append("last_name")
if avatar_url and user.avatar != avatar_url:
user.avatar = avatar_url
update_fields.append("avatar")
if avatar_url and user.avatar_asset_id:
user.avatar_asset = None
update_fields.append("avatar_asset")
if update_fields:
update_fields.append("updated_at")
@ -278,6 +352,100 @@ def html_from_text(value):
return f"<p>{escape(text)}</p>" if text else "<p></p>"
def normalize_structured_title(value, fallback):
title = value.strip() if isinstance(value, str) else ""
if not title:
return fallback
heading_match = MARKDOWN_HEADING_PATTERN.match(title)
if heading_match:
return heading_match.group(1).strip() or fallback
return title
def split_leading_markdown_heading(value):
if not isinstance(value, str):
return None, ""
lines = value.splitlines()
leading_blank_count = 0
for line in lines:
if line.strip():
break
leading_blank_count += 1
if leading_blank_count >= len(lines):
return None, ""
first_content_line = lines[leading_blank_count]
heading_match = MARKDOWN_HEADING_PATTERN.match(first_content_line)
if not heading_match:
return None, value.strip()
body_lines = lines[leading_blank_count + 1 :]
while body_lines and not body_lines[0].strip():
body_lines.pop(0)
return heading_match.group(1).strip(), "\n".join(body_lines).strip()
def normalize_structured_block(block):
if not isinstance(block, dict):
return None
block_type = block.get("type")
if block_type == "text":
extracted_title, body = split_leading_markdown_heading(block.get("body"))
title = normalize_structured_title(block.get("title") or extracted_title, "Раздел")
return {
"id": str(block.get("id") or f"text-{title.lower().replace(' ', '-')}"),
"type": "text",
"title": title,
"body": body,
}
if block_type == "checker":
title = normalize_structured_title(block.get("title"), "Чеклист")
raw_items = block.get("items")
items = []
if isinstance(raw_items, list):
for item in raw_items:
if not isinstance(item, dict):
continue
text = item.get("text")
if not isinstance(text, str) or not text.strip():
continue
items.append(
{
"id": str(item.get("id") or f"item-{len(items) + 1}"),
"text": text.strip(),
"checked": item.get("checked") is True,
}
)
return {
"id": str(block.get("id") or f"checker-{title.lower().replace(' ', '-')}"),
"type": "checker",
"title": title,
"items": items,
}
return None
def normalize_structured_blocks(structured_blocks):
normalized_blocks = []
for block in structured_blocks:
normalized_block = normalize_structured_block(block)
if normalized_block is None:
return None
normalized_blocks.append(normalized_block)
return normalized_blocks
def merge_structured_blocks(detail_layout, structured_blocks):
if structured_blocks is None:
return detail_layout or {}
@ -285,8 +453,12 @@ def merge_structured_blocks(detail_layout, structured_blocks):
if not isinstance(structured_blocks, list):
return None
normalized_blocks = normalize_structured_blocks(structured_blocks)
if normalized_blocks is None:
return None
updated_layout = dict(detail_layout or {})
updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = structured_blocks
updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = normalized_blocks
return updated_layout
@ -301,6 +473,72 @@ def update_issue_labels(issue, label_ids):
return True
def normalize_label_color(value):
if isinstance(value, str) and HEX_COLOR_PATTERN.match(value.strip()):
return value.strip()
return DEFAULT_AGENT_LABEL_COLOR
def normalize_label_input(raw_label):
if isinstance(raw_label, str):
name = raw_label.strip()
color = DEFAULT_AGENT_LABEL_COLOR
elif isinstance(raw_label, dict):
name = raw_label.get("name").strip() if isinstance(raw_label.get("name"), str) else ""
color = normalize_label_color(raw_label.get("color"))
else:
return None
if not name:
return None
return {
"name": name[:255],
"color": color,
}
def ensure_project_labels(project, raw_labels, actor, agent_id):
if not isinstance(raw_labels, list) or not raw_labels or len(raw_labels) > 50:
return None
ensured_labels = []
for raw_label in raw_labels:
label_input = normalize_label_input(raw_label)
if label_input is None:
return None
label = Label.objects.filter(
project=project,
name__iexact=label_input["name"],
deleted_at__isnull=True,
).first()
if label is None:
try:
label = Label(
project=project,
workspace=project.workspace,
name=label_input["name"],
color=label_input["color"],
external_source=AGENT_BOT_TYPE,
external_id=agent_id,
)
label.save(created_by_id=actor.id)
except IntegrityError:
label = Label.objects.filter(
project=project,
name__iexact=label_input["name"],
deleted_at__isnull=True,
).first()
if label is None:
return None
ensured_labels.append(label)
return ensured_labels
def update_issue_assignees(issue, member_ids):
project_members = list(
ProjectMember.objects.filter(
@ -413,6 +651,42 @@ class NodeDCAgentProjectContextEndpoint(View):
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectLabelsEnsureEndpoint(View):
def post(self, request, project_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project = resolve_project(project_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None:
return validation_error("missing_agent_headers")
identity = get_agent_identity(request)
ensured_labels = ensure_project_labels(
project,
payload.get("labels"),
actor,
identity["agent_id"] if identity else "",
)
if ensured_labels is None:
return validation_error("invalid_labels")
return JsonResponse({"ok": True, "labels": [serialize_label(label) for label in ensured_labels]}, status=201)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueListEndpoint(View):
def get(self, request):
@ -473,7 +747,7 @@ class NodeDCAgentIssueListEndpoint(View):
return validation_error("invalid_structured_blocks")
with transaction.atomic():
actor = ensure_agent_actor(request, project.workspace, project)
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None:
return validation_error("missing_agent_headers")
@ -539,7 +813,7 @@ class NodeDCAgentIssueUpdateEndpoint(View):
update_fields.append("detail_layout")
if update_fields:
actor = ensure_agent_actor(request, project.workspace, project)
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None:
return validation_error("missing_agent_headers")
issue.updated_by = actor
@ -575,7 +849,7 @@ class NodeDCAgentIssueMoveEndpoint(View):
if state is None:
return validation_error("state_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project)
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None:
return validation_error("missing_agent_headers")
@ -615,7 +889,7 @@ class NodeDCAgentIssueCommentEndpoint(View):
if not isinstance(body, str) or not body.strip():
return validation_error("body_required")
actor = ensure_agent_actor(request, project.workspace, project)
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None:
return validation_error("missing_agent_headers")
@ -659,7 +933,7 @@ class NodeDCAgentIssueLabelsEndpoint(View):
if not update_issue_labels(issue, label_ids):
return validation_error("label_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project)
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is not None:
issue.updated_by = actor
issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)
@ -696,7 +970,7 @@ class NodeDCAgentIssueAssigneesEndpoint(View):
if not update_issue_assignees(issue, member_ids):
return validation_error("member_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project)
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is not None:
issue.updated_by = actor
issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)

View File

@ -23,6 +23,7 @@ from plane.authentication.views.nodedc_agent_adapter import (
NodeDCAgentIssueMoveEndpoint,
NodeDCAgentIssueUpdateEndpoint,
NodeDCAgentProjectContextEndpoint,
NodeDCAgentProjectLabelsEnsureEndpoint,
NodeDCAgentProjectResolveEndpoint,
)
from plane.authentication.views.nodedc_workspace_adapter import (
@ -94,6 +95,11 @@ urlpatterns = [
NodeDCAgentProjectContextEndpoint.as_view(),
name="nodedc-agent-project-context",
),
path(
"api/internal/nodedc/agent/projects/<uuid:project_id>/labels/ensure",
NodeDCAgentProjectLabelsEnsureEndpoint.as_view(),
name="nodedc-agent-project-labels-ensure",
),
path(
"api/internal/nodedc/agent/issues",
NodeDCAgentIssueListEndpoint.as_view(),

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { lazy, Suspense, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
@ -14,7 +14,6 @@ import { ChevronDownIcon } from "@plane/propel/icons";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import {
DisplayFiltersSelection,
FiltersDropdown,
@ -24,6 +23,12 @@ import {
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
const WorkItemsModal = lazy(() =>
import("@/components/analytics/work-items/modal").then((module) => ({
default: module.WorkItemsModal,
}))
);
export const ProjectIssuesMobileHeader = observer(function ProjectIssuesMobileHeader() {
// i18n
const { t } = useTranslation();
@ -63,11 +68,15 @@ export const ProjectIssuesMobileHeader = observer(function ProjectIssuesMobileHe
return (
<>
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
/>
{analyticsModal && (
<Suspense fallback={null}>
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
/>
</Suspense>
)}
<div className="z-[13] flex justify-evenly border-b border-subtle bg-surface-1 py-2 md:hidden">
<LayoutSelection
layouts={[

View File

@ -10,7 +10,7 @@ import { useTranslation } from "@plane/i18n";
import { PlusIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
// components
import { TopNavPowerK } from "@/components/navigation";
import { DeferredTopNavPowerK } from "@/components/navigation/deferred-top-nav-power-k";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { ProjectsToolbarMenu } from "./projects-toolbar-menu";
@ -40,7 +40,7 @@ export const CompactProjectShellToolbarLayout = ({
<div className="flex min-w-0 items-center gap-3">
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<WorkspaceMenuRoot variant="toolbar" />
<TopNavPowerK variant="sidebar" />
<DeferredTopNavPowerK variant="sidebar" />
<UserMenuRoot variant="toolbar" />
<ToolbarNotificationsButton
label={t("notification.label")}

View File

@ -10,7 +10,7 @@ import { useTranslation } from "@plane/i18n";
import { Shapes } from "lucide-react";
import { cn } from "@plane/utils";
// components
import { TopNavPowerK } from "@/components/navigation";
import { DeferredTopNavPowerK } from "@/components/navigation/deferred-top-nav-power-k";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
@ -71,7 +71,7 @@ export const ExpandedProjectShellToolbarLayout = ({
<div className="nodedc-expanded-breadcrumbs-slot" data-nodedc-expanded-breadcrumbs-slot />
{!isWorkspaceHome && (
<div className="nodedc-expanded-main-tool-cluster">
<TopNavPowerK variant="expanded-toolbar" />
<DeferredTopNavPowerK variant="expanded-toolbar" />
<div className="nodedc-expanded-header-filters-slot" data-nodedc-expanded-header-filters-slot />
</div>
)}

View File

@ -10,9 +10,7 @@ import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/conten
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
import { GlobalModals } from "@/plane-web/components/common/modal/global";
import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper";
import { ProjectSettingsModal } from "@/components/project/settings/project-settings-modal";
import { WorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal";
import { WorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal";
import { LazyWorkspaceModals } from "@/components/common/modal/lazy-workspace-modals";
import type { Route } from "./+types/layout";
export default function WorkspaceLayout(props: Route.ComponentProps) {
@ -24,9 +22,7 @@ export default function WorkspaceLayout(props: Route.ComponentProps) {
<AppRailVisibilityProvider>
<WorkspaceContentWrapper workspaceSlug={workspaceSlug}>
<GlobalModals workspaceSlug={workspaceSlug} />
<WorkspaceSettingsModal />
<ProjectSettingsModal />
<WorkspaceNotificationsModal />
<LazyWorkspaceModals />
<Outlet />
</WorkspaceContentWrapper>
</AppRailVisibilityProvider>

View File

@ -6,6 +6,7 @@
import { lazy, Suspense } from "react";
import { observer } from "mobx-react";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
const ProfileSettingsModal = lazy(() =>
import("@/components/settings/profile/modal").then((module) => ({
@ -24,6 +25,10 @@ type TGlobalModalsProps = {
* - Profile settings modal
*/
export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) {
const { profileSettingsModal } = useCommandPalette();
if (!profileSettingsModal.isOpen) return null;
return (
<Suspense fallback={null}>
<ProfileSettingsModal />

View File

@ -8,7 +8,7 @@
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { DeferredTopNavPowerK } from "@/components/navigation/deferred-top-nav-power-k";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
@ -57,7 +57,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
</div>
{/* Power K Search */}
<div className="shrink-0">
<TopNavPowerK />
<DeferredTopNavPowerK />
</div>
{/* Additional Actions */}
<div className="flex flex-1 shrink-0 items-center justify-end gap-1">

View File

@ -7,14 +7,21 @@
// store
import { CoreRootStore } from "@/store/root.store";
import type { ITimelineStore } from "./timeline";
import { TimeLineStore } from "./timeline";
export class RootStore extends CoreRootStore {
timelineStore: ITimelineStore;
timelineStore: ITimelineStore | undefined;
private timelineStorePromise: Promise<ITimelineStore> | undefined;
constructor() {
super();
loadTimelineStore = async (): Promise<ITimelineStore> => {
if (this.timelineStore) return this.timelineStore;
this.timelineStore = new TimeLineStore(this);
if (!this.timelineStorePromise) {
this.timelineStorePromise = import("./timeline").then(({ TimeLineStore }) => {
this.timelineStore = new TimeLineStore(this);
return this.timelineStore;
});
}
return this.timelineStorePromise;
}
}

View File

@ -0,0 +1,94 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { lazy, Suspense, useEffect, useState } from "react";
import {
getProjectSettingsModalProjectIdFromSearch,
getProjectSettingsModalTabFromSearch,
PROJECT_SETTINGS_MODAL_EVENT,
} from "@/components/project/settings/project-settings-modal.utils";
import {
getWorkspaceSettingsModalTabFromSearch,
WORKSPACE_SETTINGS_MODAL_EVENT,
} from "@/components/workspace/settings/workspace-settings-modal.utils";
import {
getWorkspaceNotificationsModalOpenFromSearch,
WORKSPACE_NOTIFICATIONS_MODAL_EVENT,
} from "@/components/workspace-notifications/notifications-modal.utils";
const WorkspaceSettingsModal = lazy(() =>
import("@/components/workspace/settings/workspace-settings-modal").then((module) => ({
default: module.WorkspaceSettingsModal,
}))
);
const ProjectSettingsModal = lazy(() =>
import("@/components/project/settings/project-settings-modal").then((module) => ({
default: module.ProjectSettingsModal,
}))
);
const WorkspaceNotificationsModal = lazy(() =>
import("@/components/workspace-notifications/notifications-modal").then((module) => ({
default: module.WorkspaceNotificationsModal,
}))
);
const hasWorkspaceSettingsSearch = () =>
typeof window !== "undefined" && Boolean(getWorkspaceSettingsModalTabFromSearch(window.location.search));
const hasProjectSettingsSearch = () =>
typeof window !== "undefined" &&
Boolean(getProjectSettingsModalTabFromSearch(window.location.search) && getProjectSettingsModalProjectIdFromSearch(window.location.search));
const hasWorkspaceNotificationsSearch = () =>
typeof window !== "undefined" && getWorkspaceNotificationsModalOpenFromSearch(window.location.search);
export function LazyWorkspaceModals() {
const [shouldLoadWorkspaceSettings, setShouldLoadWorkspaceSettings] = useState(hasWorkspaceSettingsSearch);
const [shouldLoadProjectSettings, setShouldLoadProjectSettings] = useState(hasProjectSettingsSearch);
const [shouldLoadWorkspaceNotifications, setShouldLoadWorkspaceNotifications] = useState(hasWorkspaceNotificationsSearch);
useEffect(() => {
const syncFromLocation = () => {
if (hasWorkspaceSettingsSearch()) setShouldLoadWorkspaceSettings(true);
if (hasProjectSettingsSearch()) setShouldLoadProjectSettings(true);
if (hasWorkspaceNotificationsSearch()) setShouldLoadWorkspaceNotifications(true);
};
const handleWorkspaceSettingsEvent = (event: Event) => {
if ((event as CustomEvent<{ isOpen: boolean }>).detail?.isOpen) setShouldLoadWorkspaceSettings(true);
};
const handleProjectSettingsEvent = (event: Event) => {
if ((event as CustomEvent<{ isOpen: boolean }>).detail?.isOpen) setShouldLoadProjectSettings(true);
};
const handleWorkspaceNotificationsEvent = (event: Event) => {
if ((event as CustomEvent<{ isOpen: boolean }>).detail?.isOpen) setShouldLoadWorkspaceNotifications(true);
};
window.addEventListener("popstate", syncFromLocation);
window.addEventListener(WORKSPACE_SETTINGS_MODAL_EVENT, handleWorkspaceSettingsEvent);
window.addEventListener(PROJECT_SETTINGS_MODAL_EVENT, handleProjectSettingsEvent);
window.addEventListener(WORKSPACE_NOTIFICATIONS_MODAL_EVENT, handleWorkspaceNotificationsEvent);
return () => {
window.removeEventListener("popstate", syncFromLocation);
window.removeEventListener(WORKSPACE_SETTINGS_MODAL_EVENT, handleWorkspaceSettingsEvent);
window.removeEventListener(PROJECT_SETTINGS_MODAL_EVENT, handleProjectSettingsEvent);
window.removeEventListener(WORKSPACE_NOTIFICATIONS_MODAL_EVENT, handleWorkspaceNotificationsEvent);
};
}, []);
return (
<Suspense fallback={null}>
{shouldLoadWorkspaceSettings && <WorkspaceSettingsModal />}
{shouldLoadProjectSettings && <ProjectSettingsModal />}
{shouldLoadWorkspaceNotifications && <WorkspaceNotificationsModal />}
</Suspense>
);
}

View File

@ -50,15 +50,17 @@ export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) {
const storeType = useIssueStoreType();
const { issues } = useIssues(storeType);
useIssueRealtimeEvents(storeType, workspaceSlug?.toString(), projectId?.toString());
const issueLoader = issues?.getIssueLoader();
const issueCount = issues?.getGroupIssueCount(undefined, undefined, false);
const isInitialLoadComplete = !!issues && issueLoader !== "init-loader" && issueCount !== undefined;
useIssueRealtimeEvents(storeType, workspaceSlug?.toString(), projectId?.toString(), isInitialLoadComplete);
if (!issues) {
return <ActiveLoader layout={layout} />;
}
const issueCount = issues.getGroupIssueCount(undefined, undefined, false);
if (issues?.getIssueLoader() === "init-loader" || issueCount === undefined) {
if (issueLoader === "init-loader" || issueCount === undefined) {
return <ActiveLoader layout={layout} />;
}

View File

@ -341,6 +341,7 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
horizontalOffset={100}
verticalOffset={200}
defaultValue={shouldRenderByDefault}
useIdletime
>
<KanbanIssueDetailsBlock
cardRef={cardRef}

View File

@ -67,7 +67,7 @@ export const KanbanIssueBlocksList = observer(function KanbanIssueBlocksList(pro
issueId={issueId}
groupId={groupId}
subGroupId={sub_group_id}
shouldRenderByDefault={index <= 10}
shouldRenderByDefault={index < 3}
issuesMap={issuesMap}
displayProperties={displayProperties}
updateIssue={updateIssue}

View File

@ -210,7 +210,7 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
shouldAnimate={false}
/>
}
defaultValue={groupIndex < 5 && subGroupIndex < 2}
defaultValue={groupIndex < 3 && subGroupIndex < 2}
useIdletime
>
<KanbanGroup

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import React, { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
@ -19,15 +19,26 @@ import { TransferIssuesModal } from "@/components/cycles/transfer-issues-modal";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { CycleCalendarLayout } from "../calendar/roots/cycle-root";
import { BaseGanttRoot } from "../gantt";
import { CycleKanBanLayout } from "../kanban/roots/cycle-root";
import { CycleListLayout } from "../list/roots/cycle-root";
import { CycleSpreadsheetLayout } from "../spreadsheet/roots/cycle-root";
const IssuePeekOverview = lazy(() =>
import("../../peek-overview/root").then((module) => ({ default: module.IssuePeekOverview }))
);
const CycleCalendarLayout = lazy(() =>
import("../calendar/roots/cycle-root").then((module) => ({ default: module.CycleCalendarLayout }))
);
const BaseGanttRoot = lazy(() => import("../gantt").then((module) => ({ default: module.BaseGanttRoot })));
const CycleKanBanLayout = lazy(() =>
import("../kanban/roots/cycle-root").then((module) => ({ default: module.CycleKanBanLayout }))
);
const CycleListLayout = lazy(() =>
import("../list/roots/cycle-root").then((module) => ({ default: module.CycleListLayout }))
);
const CycleSpreadsheetLayout = lazy(() =>
import("../spreadsheet/roots/cycle-root").then((module) => ({ default: module.CycleSpreadsheetLayout }))
);
function CycleIssueLayout(props: {
activeLayout: EIssueLayoutTypes | undefined;
@ -58,6 +69,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
// store hooks
const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
const { getCycleById } = useCycle();
const { peekIssue } = useIssueDetail();
// state
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
// derived values
@ -82,6 +94,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
? cycleDetails.backlog_issues + cycleDetails.unstarted_issues + cycleDetails.started_issues
: 0;
const canTransferIssues = isProgressSnapshotEmpty && transferableIssuesCount > 0;
const shouldRenderPeekOverview = !!peekIssue?.workspaceSlug && !!peekIssue?.projectId && !!peekIssue?.issueId;
if (!workspaceSlug || !projectId || !cycleId || !workItemFilters) return <></>;
return (
@ -120,10 +133,19 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
/>
)}
<div className="h-full w-full overflow-auto">
<CycleIssueLayout activeLayout={activeLayout} cycleId={cycleId} isCompletedCycle={isCompletedCycle} />
<Suspense fallback={null}>
<CycleIssueLayout
activeLayout={activeLayout}
cycleId={cycleId}
isCompletedCycle={isCompletedCycle}
/>
</Suspense>
</div>
{/* peek overview */}
<IssuePeekOverview />
{shouldRenderPeekOverview && (
<Suspense fallback={null}>
<IssuePeekOverview />
</Suspense>
)}
</div>
</>
)}

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import React from "react";
import { lazy, Suspense } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
@ -15,15 +15,26 @@ import { Row, ERowVariant } from "@plane/ui";
// hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { ModuleCalendarLayout } from "../calendar/roots/module-root";
import { BaseGanttRoot } from "../gantt";
import { ModuleKanBanLayout } from "../kanban/roots/module-root";
import { ModuleListLayout } from "../list/roots/module-root";
import { ModuleSpreadsheetLayout } from "../spreadsheet/roots/module-root";
const IssuePeekOverview = lazy(() =>
import("../../peek-overview/root").then((module) => ({ default: module.IssuePeekOverview }))
);
const ModuleCalendarLayout = lazy(() =>
import("../calendar/roots/module-root").then((module) => ({ default: module.ModuleCalendarLayout }))
);
const BaseGanttRoot = lazy(() => import("../gantt").then((module) => ({ default: module.BaseGanttRoot })));
const ModuleKanBanLayout = lazy(() =>
import("../kanban/roots/module-root").then((module) => ({ default: module.ModuleKanBanLayout }))
);
const ModuleListLayout = lazy(() =>
import("../list/roots/module-root").then((module) => ({ default: module.ModuleListLayout }))
);
const ModuleSpreadsheetLayout = lazy(() =>
import("../spreadsheet/roots/module-root").then((module) => ({ default: module.ModuleSpreadsheetLayout }))
);
function ModuleIssueLayout(props: { activeLayout: EIssueLayoutTypes | undefined; moduleId: string }) {
switch (props.activeLayout) {
@ -50,9 +61,11 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
const moduleId = routerModuleId ? routerModuleId.toString() : undefined;
// hooks
const { issuesFilter } = useIssues(EIssuesStoreType.MODULE);
const { peekIssue } = useIssueDetail();
// derived values
const workItemFilters = moduleId ? issuesFilter?.getIssueFilters(moduleId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout || undefined;
const shouldRenderPeekOverview = !!peekIssue?.workspaceSlug && !!peekIssue?.projectId && !!peekIssue?.issueId;
useSWR(
workspaceSlug && projectId && moduleId
@ -90,10 +103,15 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
/>
)}
<Row variant={ERowVariant.HUGGING} className="h-full w-full overflow-auto">
<ModuleIssueLayout activeLayout={activeLayout} moduleId={moduleId} />
<Suspense fallback={null}>
<ModuleIssueLayout activeLayout={activeLayout} moduleId={moduleId} />
</Suspense>
</Row>
{/* peek overview */}
<IssuePeekOverview />
{shouldRenderPeekOverview && (
<Suspense fallback={null}>
<IssuePeekOverview />
</Suspense>
)}
</div>
)}
</ProjectLevelWorkItemFiltersHOC>

View File

@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import { lazy, Suspense } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
@ -15,15 +16,24 @@ import { Spinner } from "@plane/ui";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { CalendarLayout } from "../calendar/roots/project-root";
import { BaseGanttRoot } from "../gantt";
import { KanBanLayout } from "../kanban/roots/project-root";
import { ListLayout } from "../list/roots/project-root";
import { ProjectSpreadsheetLayout } from "../spreadsheet/roots/project-root";
const CalendarLayout = lazy(() =>
import("../calendar/roots/project-root").then((module) => ({ default: module.CalendarLayout }))
);
const BaseGanttRoot = lazy(() => import("../gantt").then((module) => ({ default: module.BaseGanttRoot })));
const IssuePeekOverview = lazy(() =>
import("../../peek-overview/root").then((module) => ({ default: module.IssuePeekOverview }))
);
const KanBanLayout = lazy(() =>
import("../kanban/roots/project-root").then((module) => ({ default: module.KanBanLayout }))
);
const ListLayout = lazy(() => import("../list/roots/project-root").then((module) => ({ default: module.ListLayout })));
const ProjectSpreadsheetLayout = lazy(() =>
import("../spreadsheet/roots/project-root").then((module) => ({ default: module.ProjectSpreadsheetLayout }))
);
function ProjectIssueLayout(props: { activeLayout: EIssueLayoutTypes | undefined }) {
switch (props.activeLayout) {
@ -49,9 +59,11 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
const { peekIssue } = useIssueDetail();
// derived values
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout;
const shouldRenderPeekOverview = !!peekIssue?.workspaceSlug && !!peekIssue?.projectId && !!peekIssue?.issueId;
useSWR(
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
@ -93,10 +105,15 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
<Spinner className="h-4 w-4" />
</div>
)}
<ProjectIssueLayout activeLayout={activeLayout} />
<Suspense fallback={null}>
<ProjectIssueLayout activeLayout={activeLayout} />
</Suspense>
</div>
{/* peek overview */}
<IssuePeekOverview />
{shouldRenderPeekOverview && (
<Suspense fallback={null}>
<IssuePeekOverview />
</Suspense>
)}
</div>
)}
</ProjectLevelWorkItemFiltersHOC>

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import React, { useEffect } from "react";
import { lazy, Suspense, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
@ -14,16 +14,27 @@ import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useProjectView } from "@/hooks/store/use-project-view";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { ProjectViewCalendarLayout } from "../calendar/roots/project-view-root";
import { BaseGanttRoot } from "../gantt";
import { ProjectViewKanBanLayout } from "../kanban/roots/project-view-root";
import { ProjectViewListLayout } from "../list/roots/project-view-root";
import { ProjectViewSpreadsheetLayout } from "../spreadsheet/roots/project-view-root";
const IssuePeekOverview = lazy(() =>
import("../../peek-overview/root").then((module) => ({ default: module.IssuePeekOverview }))
);
const ProjectViewCalendarLayout = lazy(() =>
import("../calendar/roots/project-view-root").then((module) => ({ default: module.ProjectViewCalendarLayout }))
);
const BaseGanttRoot = lazy(() => import("../gantt").then((module) => ({ default: module.BaseGanttRoot })));
const ProjectViewKanBanLayout = lazy(() =>
import("../kanban/roots/project-view-root").then((module) => ({ default: module.ProjectViewKanBanLayout }))
);
const ProjectViewListLayout = lazy(() =>
import("../list/roots/project-view-root").then((module) => ({ default: module.ProjectViewListLayout }))
);
const ProjectViewSpreadsheetLayout = lazy(() =>
import("../spreadsheet/roots/project-view-root").then((module) => ({ default: module.ProjectViewSpreadsheetLayout }))
);
function ProjectViewIssueLayout(props: { activeLayout: EIssueLayoutTypes | undefined; viewId: string }) {
switch (props.activeLayout) {
@ -51,6 +62,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
// hooks
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW);
const { getViewById } = useProjectView();
const { peekIssue } = useIssueDetail();
// derived values
const projectView = viewId ? getViewById(viewId) : undefined;
const workItemFilters = viewId ? issuesFilter?.getIssueFilters(viewId) : undefined;
@ -63,6 +75,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
richFilters: projectView.rich_filters,
}
: undefined;
const shouldRenderPeekOverview = !!peekIssue?.workspaceSlug && !!peekIssue?.projectId && !!peekIssue?.issueId;
useSWR(
workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null,
@ -110,10 +123,15 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
/>
)}
<div className="relative h-full w-full overflow-auto">
<ProjectViewIssueLayout activeLayout={activeLayout} viewId={viewId.toString()} />
<Suspense fallback={null}>
<ProjectViewIssueLayout activeLayout={activeLayout} viewId={viewId.toString()} />
</Suspense>
</div>
{/* peek overview */}
<IssuePeekOverview />
{shouldRenderPeekOverview && (
<Suspense fallback={null}>
<IssuePeekOverview />
</Suspense>
)}
</div>
)}
</ProjectLevelWorkItemFiltersHOC>

View File

@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import { lazy, Suspense } from "react";
import { observer } from "mobx-react";
import { useParams, useSearchParams } from "next/navigation";
// components
@ -14,7 +15,7 @@ import { EUserProjectRoles } from "@plane/types";
import { ContentWrapper, Row, ERowVariant } from "@plane/ui";
// components
import { ListLayout } from "@/components/core/list";
import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "@/components/modules";
import { ModuleCardItem, ModuleListItem, ModulePeekOverview } from "@/components/modules";
import { CycleModuleBoardLayoutLoader } from "@/components/ui/loader/cycle-module-board-loader";
import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader";
import { GanttLayoutLoader } from "@/components/ui/loader/layouts/gantt-layout-loader";
@ -24,6 +25,12 @@ import { useModule } from "@/hooks/store/use-module";
import { useModuleFilter } from "@/hooks/store/use-module-filter";
import { useUserPermissions } from "@/hooks/store/user";
const ModulesListGanttChartView = lazy(() =>
import("@/components/modules/gantt-chart/modules-list-layout").then((module) => ({
default: module.ModulesListGanttChartView,
}))
);
export const ModulesListView = observer(function ModulesListView() {
// router
const { workspaceSlug, projectId } = useParams();
@ -105,7 +112,9 @@ export const ModulesListView = observer(function ModulesListView() {
)}
{displayFilters?.layout === "gantt" && (
<div className="size-full overflow-hidden">
<ModulesListGanttChartView />
<Suspense fallback={<GanttLayoutLoader />}>
<ModulesListGanttChartView />
</Suspense>
</div>
)}
<div className="flex-shrink-0">

View File

@ -0,0 +1,105 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "@plane/i18n";
import { SearchIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
const TopNavPowerK = lazy(() =>
import("./top-nav-power-k").then((module) => ({
default: module.TopNavPowerK,
}))
);
type TDeferredTopNavPowerKProps = {
variant?: "top-navigation" | "sidebar" | "expanded-toolbar";
};
export const DeferredTopNavPowerK = ({ variant = "top-navigation" }: TDeferredTopNavPowerKProps) => {
const { t } = useTranslation();
const [shouldLoad, setShouldLoad] = useState(false);
const [shouldAutoOpen, setShouldAutoOpen] = useState(false);
const searchLabel = t("power_k.search_menu.quick_command_placeholder");
const handleOpen = () => {
setShouldAutoOpen(true);
setShouldLoad(true);
};
if (shouldLoad) {
return (
<Suspense fallback={<TopNavPowerKFallback variant={variant} onOpen={handleOpen} label={searchLabel} />}>
<TopNavPowerK
autoOpen={shouldAutoOpen}
variant={variant}
onAutoOpenComplete={() => setShouldAutoOpen(false)}
/>
</Suspense>
);
}
return <TopNavPowerKFallback variant={variant} onOpen={handleOpen} label={searchLabel} />;
};
const TopNavPowerKFallback = ({
label,
onOpen,
variant,
}: {
label: string;
onOpen: () => void;
variant: "top-navigation" | "sidebar" | "expanded-toolbar";
}) => {
if (variant === "expanded-toolbar") {
return (
<div className="nodedc-expanded-search-control" data-open={false}>
<button
type="button"
className="nodedc-expanded-tool-button nodedc-expanded-search-trigger"
aria-label={label}
aria-pressed={false}
onClick={onOpen}
>
<SearchIcon className="size-4 shrink-0" />
</button>
</div>
);
}
if (variant === "sidebar") {
return (
<div className="relative z-30 h-8 w-8">
<button
type="button"
className="absolute left-0 top-0 z-[161] flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
aria-label={label}
onClick={onOpen}
>
<SearchIcon className="size-3.5 shrink-0 text-placeholder" />
</button>
</div>
);
}
return (
<div className="relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out">
<button
type="button"
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 text-left transition-colors duration-200"
)}
aria-label={label}
onClick={onOpen}
>
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
<span className="min-w-0 flex-1 text-13 text-placeholder">{label}</span>
</button>
</div>
);
};

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import React, { useEffect } from "react";
import React, { lazy, Suspense, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams, useLocation, Link, useNavigate } from "react-router";
import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
@ -18,8 +18,6 @@ import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { useNavigationItems } from "@/plane-web/components/navigations";
// local imports
import { LeaveProjectModal } from "../project/leave-project-modal";
import { PublishProjectModal } from "../project/publish-project/modal";
import { ProjectActionsMenu } from "./project-actions-menu";
import { ProjectHeader } from "./project-header";
import { TabNavigationOverflowMenu } from "./tab-navigation-overflow-menu";
@ -30,6 +28,18 @@ import { useProjectActions } from "./use-project-actions";
import { useResponsiveTabLayout } from "./use-responsive-tab-layout";
import { useTabPreferences } from "./use-tab-preferences";
const LeaveProjectModal = lazy(() =>
import("../project/leave-project-modal").then((module) => ({
default: module.LeaveProjectModal,
}))
);
const PublishProjectModal = lazy(() =>
import("../project/publish-project/modal").then((module) => ({
default: module.PublishProjectModal,
}))
);
// Local type definition for navigation items with app-specific fields
export type TNavigationItem = {
name: string;
@ -109,7 +119,7 @@ export const TabNavigationRoot = observer(function TabNavigationRoot(props: TTab
// Filter and sort navigation items
const allNavigationItems = navigationItems
.filter((item) => item.shouldRender)
.sort((a, b) => a.sortOrder - b.sortOrder);
.toSorted((a, b) => a.sortOrder - b.sortOrder);
// Split items into two categories:
// 1. visibleNavigationItems: Items NOT user-hidden (may still overflow due to space)
@ -162,12 +172,18 @@ export const TabNavigationRoot = observer(function TabNavigationRoot(props: TTab
return (
<>
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => handlePublishModal(false)} />
<LeaveProjectModal
project={project}
isOpen={leaveProjectModalOpen}
onClose={() => handleLeaveProjectModal(false)}
/>
<Suspense fallback={null}>
{publishModalOpen && (
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => handlePublishModal(false)} />
)}
{leaveProjectModalOpen && (
<LeaveProjectModal
project={project}
isOpen={leaveProjectModalOpen}
onClose={() => handleLeaveProjectModal(false)}
/>
)}
</Suspense>
{/* container for the tab navigation */}
<div className="flex size-full items-center gap-3 overflow-hidden">

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { lazy, Suspense, useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
@ -15,20 +15,32 @@ import { CloseIcon, SearchIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
// power-k
import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k/core/types";
import { ProjectsAppPowerKCommandsList } from "@/components/power-k/ui/modal/commands-list";
import { PowerKModalFooter } from "@/components/power-k/ui/modal/footer";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePowerK } from "@/hooks/store/use-power-k";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search";
const ProjectsAppPowerKCommandsList = lazy(() =>
import("@/components/power-k/ui/modal/commands-list").then((module) => ({
default: module.ProjectsAppPowerKCommandsList,
}))
);
const PowerKModalFooter = lazy(() =>
import("@/components/power-k/ui/modal/footer").then((module) => ({
default: module.PowerKModalFooter,
}))
);
type TTopNavPowerKProps = {
autoOpen?: boolean;
onAutoOpenComplete?: () => void;
variant?: "top-navigation" | "sidebar" | "expanded-toolbar";
};
export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const { variant = "top-navigation" } = props;
const { autoOpen = false, onAutoOpenComplete, variant = "top-navigation" } = props;
const { t } = useTranslation();
const isWideSearch = variant === "top-navigation" || variant === "expanded-toolbar";
const isExpandedToolbar = variant === "expanded-toolbar";
@ -51,6 +63,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const sidebarSearchPortalRef = useRef<HTMLDivElement>(null);
const sidebarSearchButtonRef = useRef<HTMLButtonElement>(null);
const autoOpenHandledRef = useRef(false);
// store hooks
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
@ -76,6 +89,15 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
additionalRefs: [sidebarSearchPortalRef],
});
useEffect(() => {
if (!autoOpen || autoOpenHandledRef.current) return;
autoOpenHandledRef.current = true;
openPanel();
requestAnimationFrame(() => inputRef.current?.focus());
onAutoOpenComplete?.();
}, [autoOpen, inputRef, onAutoOpenComplete, openPanel]);
// derived values
const {
issue: { getIssueById, getIssueIdByIdentifier },
@ -277,22 +299,26 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
>
<Command.Input value={searchTerm} hidden />
<Command.List className="vertical-scrollbar scrollbar-sm max-h-[60vh] overflow-y-auto px-2 pb-4 outline-none">
<ProjectsAppPowerKCommandsList
activePage={activePage}
context={context}
handleCommandSelect={handleCommandSelect}
handlePageDataSelection={handlePageDataSelection}
isWorkspaceLevel={isWorkspaceLevel}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
handleSearchMenuClose={() => closePanel()}
/>
<Suspense fallback={null}>
<ProjectsAppPowerKCommandsList
activePage={activePage}
context={context}
handleCommandSelect={handleCommandSelect}
handlePageDataSelection={handlePageDataSelection}
isWorkspaceLevel={isWorkspaceLevel}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
handleSearchMenuClose={() => closePanel()}
/>
</Suspense>
</Command.List>
<PowerKModalFooter
isWorkspaceLevel={isWorkspaceLevel}
projectId={context.params.projectId?.toString()}
onWorkspaceLevelChange={setIsWorkspaceLevel}
/>
<Suspense fallback={null}>
<PowerKModalFooter
isWorkspaceLevel={isWorkspaceLevel}
projectId={context.params.projectId?.toString()}
onWorkspaceLevelChange={setIsWorkspaceLevel}
/>
</Suspense>
</Command>
);

View File

@ -4,24 +4,53 @@
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { lazy, Suspense, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePowerK } from "@/hooks/store/use-power-k";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { ProjectLevelModals } from "@/plane-web/components/command-palette/modals/project-level";
import { WorkItemLevelModals } from "@/plane-web/components/command-palette/modals/work-item-level";
import { WorkspaceLevelModals } from "@/plane-web/components/command-palette/modals/workspace-level";
// local imports
import { useProjectsAppPowerKCommands } from "./config/commands";
import type { TPowerKCommandConfig, TPowerKContext } from "./core/types";
import { GlobalShortcutsProvider } from "./global-shortcuts";
import { ProjectsAppPowerKCommandsList } from "./ui/modal/commands-list";
import { ProjectsAppPowerKModalWrapper } from "./ui/modal/wrapper";
const WorkspaceLevelModals = lazy(() =>
import("@/plane-web/components/command-palette/modals/workspace-level").then((module) => ({
default: module.WorkspaceLevelModals,
}))
);
const ProjectLevelModals = lazy(() =>
import("@/plane-web/components/command-palette/modals/project-level").then((module) => ({
default: module.ProjectLevelModals,
}))
);
const WorkItemLevelModals = lazy(() =>
import("@/plane-web/components/command-palette/modals/work-item-level").then((module) => ({
default: module.WorkItemLevelModals,
}))
);
const ProjectsAppPowerKModal = lazy(async () => {
const [wrapperModule, commandsListModule] = await Promise.all([
import("./ui/modal/wrapper"),
import("./ui/modal/commands-list"),
]);
return {
default: (props: Omit<Parameters<typeof wrapperModule.ProjectsAppPowerKModalWrapper>[0], "commandsListComponent">) => (
<wrapperModule.ProjectsAppPowerKModalWrapper
{...props}
commandsListComponent={commandsListModule.ProjectsAppPowerKCommandsList}
/>
),
};
});
/**
* Projects App PowerK provider
@ -36,6 +65,16 @@ export const ProjectsAppPowerKProvider = observer(function ProjectsAppPowerKProv
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
// store hooks
const { activeContext, isPowerKModalOpen, togglePowerKModal, setActivePage } = usePowerK();
const {
createPageModal,
isBulkDeleteIssueModalOpen,
isCreateCycleModalOpen,
isCreateIssueModalOpen,
isCreateModuleModalOpen,
isCreateProjectModalOpen,
isCreateViewModalOpen,
isDeleteIssueModalOpen,
} = useCommandPalette();
const { data: currentUser } = useUser();
// derived values
const {
@ -46,6 +85,15 @@ export const ProjectsAppPowerKProvider = observer(function ProjectsAppPowerKProv
const workItemDetails = workItemId ? getIssueById(workItemId) : undefined;
const projectId: string | string[] | undefined | null = routerProjectId ?? workItemDetails?.project_id;
const commands = useProjectsAppPowerKCommands();
const shouldLoadWorkspaceLevelModals = Boolean(workspaceSlug && isCreateProjectModalOpen);
const shouldLoadProjectLevelModals = Boolean(
workspaceSlug &&
projectId &&
(isCreateCycleModalOpen || isCreateModuleModalOpen || isCreateViewModalOpen || createPageModal.isOpen)
);
const shouldLoadWorkItemLevelModals = Boolean(
isCreateIssueModalOpen || isDeleteIssueModalOpen || isBulkDeleteIssueModalOpen
);
// Build command context from props and store
const context: TPowerKContext = useMemo(
() => ({
@ -79,17 +127,16 @@ export const ProjectsAppPowerKProvider = observer(function ProjectsAppPowerKProv
return (
<>
<GlobalShortcutsProvider context={context} commands={commands} />
{workspaceSlug && <WorkspaceLevelModals workspaceSlug={workspaceSlug.toString()} />}
{workspaceSlug && projectId && (
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
<WorkItemLevelModals workItemIdentifier={workItemIdentifier?.toString()} />
<ProjectsAppPowerKModalWrapper
commandsListComponent={ProjectsAppPowerKCommandsList}
context={context}
isOpen={isPowerKModalOpen}
onClose={() => togglePowerKModal(false)}
/>
<Suspense fallback={null}>
{shouldLoadWorkspaceLevelModals && <WorkspaceLevelModals workspaceSlug={workspaceSlug!.toString()} />}
{shouldLoadProjectLevelModals && (
<ProjectLevelModals workspaceSlug={workspaceSlug!.toString()} projectId={projectId!.toString()} />
)}
{shouldLoadWorkItemLevelModals && <WorkItemLevelModals workItemIdentifier={workItemIdentifier?.toString()} />}
{isPowerKModalOpen && (
<ProjectsAppPowerKModal context={context} isOpen={isPowerKModalOpen} onClose={() => togglePowerKModal(false)} />
)}
</Suspense>
</>
);
});

View File

@ -6,7 +6,7 @@
import { type ChangeEvent, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Bot, Check, KeyRound, Route, ShieldCheck } from "lucide-react";
import { Bot, Check, Copy, KeyRound, Route, ShieldCheck } from "lucide-react";
import useSWR from "swr";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@ -40,8 +40,13 @@ const TASK_AUTHOR_SCOPES = [
];
const AGENT_AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
const MAX_AGENT_AVATAR_BYTES = 256 * 1024;
const MAX_AGENT_AVATAR_SOURCE_BYTES = 50 * 1024 * 1024;
const AGENT_AVATAR_RENDER_SIZE = 512;
const AGENT_AVATAR_OUTPUT_QUALITY = 0.86;
const OPS_AGENT_FILENAME = "OPS_AGENT.md";
const CODEX_MCP_SERVER_NAME = "nodedc-ops-agent";
const CODEX_TOKEN_ENV_VAR = "NODEDC_OPS_AGENT_TOKEN";
const DEFAULT_OPS_AGENT_MCP_ENDPOINT = "https://ops-agents.nodedc.ru/mcp";
const codexAgentService = new WorkspaceCodexAgentService();
const projectService = new ProjectService();
@ -123,6 +128,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
() => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards),
[createdSetupCards, persistedSetupCards]
);
const connectionGuideMcpEndpoint = getMcpEndpoint(setupCards.find((card) => card.setup)?.setup);
const connectionGuideConfigSnippet = buildCodexConfigSnippet(connectionGuideMcpEndpoint);
const connectionGuideOpsAgentMd = buildOpsAgentMarkdown(connectionGuideMcpEndpoint);
const handleCopy = async (value: string, label: string) => {
await navigator.clipboard.writeText(value);
@ -241,8 +249,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
await mutateSetupCards();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Новый token выпущен",
message: "Скопируйте token сейчас. После перезахода backend вернет только masked suffix.",
title: "Новый токен выпущен",
message: "Скопируйте токен сейчас. После перезахода backend вернет только masked suffix.",
});
} catch (error: any) {
setToast({
@ -258,6 +266,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const handleSaveAgentName = async (agent: TCodexAgent) => {
const displayName = getAgentDraftName(agentDraftNames, agent).trim();
if (!displayName) return;
if (displayName === agent.display_name) return;
setUpdatingAgentIds((current) => ({ ...current, [agent.id]: true }));
try {
@ -422,7 +431,6 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const isAgentDirty = draftName.trim() !== agent.display_name;
const setupCard = setupCards.find((card) => card.agent.id === agent.id);
const agentTokens = setupCard?.tokens ?? [];
const setup = setupCard?.setup;
return (
<section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
@ -456,6 +464,14 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
[agent.id]: event.target.value,
}))
}
onBlur={() => {
if (isAgentDirty && !isUpdatingAgent) void handleSaveAgentName(agent);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
placeholder="Имя агента"
/>
</label>
@ -483,7 +499,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)}
>
Новый token
Новый токен
</Button>
<Button
variant="secondary"
@ -498,73 +514,32 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
{areSetupCardsLoading && agentTokens.length === 0 ? (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Загрузка token и Ops Agent.md...
Загрузка токена...
</div>
) : agentTokens.length > 0 ? (
<div className="grid gap-4">
{agentTokens.map((token) => {
const revealedToken = revealedTokens[token.id];
const tokenValue = revealedToken ?? maskToken(token);
const isTokenRevealed = Boolean(revealedToken);
return (
<div key={token.id} className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)]">
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Agent token
</div>
<code className="nodedc-settings-input block min-h-12 px-3 py-3 text-12 break-all text-primary">
{tokenValue}
</code>
{isTokenRevealed && (
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(revealedToken, "Токен")}
>
Скопировать токен
</Button>
</div>
)}
<div key={token.id} className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Agent token
</div>
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Ops Agent.md
</div>
{setup?.agents_md ? (
<>
<textarea
readOnly
className="nodedc-settings-input font-mono h-64 w-full resize-y px-3 py-3 text-12"
value={setup.agents_md}
/>
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(setup.agents_md ?? "", "Ops Agent.md")}
>
Скопировать Ops Agent.md
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => handleDownload(setup.agents_md ?? "", OPS_AGENT_FILENAME)}
>
Скачать
</Button>
</div>
</>
) : (
<div className="nodedc-settings-input flex min-h-24 items-center px-4 text-13 text-secondary">
Setup packet пока недоступен. Проверьте Gateway и grants агента.
</div>
)}
<div className="relative">
<code className="nodedc-settings-input flex h-12 w-full items-center overflow-hidden px-4 pr-14 text-12 text-primary">
<span className="truncate">{tokenValue}</span>
</code>
<button
type="button"
aria-label="Скопировать токен"
className="absolute top-1/2 right-1.5 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] transition hover:opacity-90 disabled:bg-white/10 disabled:text-tertiary disabled:opacity-60"
disabled={!revealedToken}
onClick={() => revealedToken && void handleCopy(revealedToken, "Токен")}
>
<Copy className="size-4" />
</button>
</div>
</div>
);
@ -572,7 +547,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</div>
) : (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Token ещё не выпущен. Нажмите «Новый token», чтобы получить token и Ops Agent.md.
Токен ещё не выпущен. Нажмите «Новый токен», чтобы получить доступ для локального Codex.
</div>
)}
</section>
@ -580,9 +555,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
})
) : (
<div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary">
Агентов пока нет. Создайте агента, выберите project и сразу получите token + Ops Agent.md.
Агентов пока нет. Создайте агента, выберите project и сразу получите токен доступа.
</div>
)}
{activeAgents.length > 0 && (
<section className="nodedc-settings-card px-5 py-5">
<CodexConnectionGuide
configSnippet={connectionGuideConfigSnippet}
mcpEndpoint={connectionGuideMcpEndpoint}
onCopyConfig={() => void handleCopy(connectionGuideConfigSnippet, "config.toml")}
onDownloadAgentsMd={() => handleDownload(connectionGuideOpsAgentMd, OPS_AGENT_FILENAME)}
/>
</section>
)}
</>
)}
</>
@ -591,6 +577,154 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
);
});
type TCodexConnectionGuideProps = {
configSnippet: string;
mcpEndpoint: string;
onCopyConfig: () => void;
onDownloadAgentsMd: () => void;
};
function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
return (
<div className="grid gap-4 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-14 font-semibold text-primary">Подключение локального Codex</div>
</div>
<span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
MCP endpoint · {props.mcpEndpoint}
</span>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">1. Найдите config.toml</div>
<p>Windows: откройте файл через проводник или VS Code.</p>
<code className="mt-2 block break-all text-12 text-primary">
C:\Users\имя-пользователя\.codex\config.toml
</code>
<p className="mt-3">macOS / Linux:</p>
<code className="mt-2 block break-all text-12 text-primary">~/.codex/config.toml</code>
<p className="mt-3">Если файла нет создайте его.</p>
</div>
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">2. Сохраните токен</div>
<p>
Создайте пользовательскую переменную окружения <code>{CODEX_TOKEN_ENV_VAR}</code>, заменив токен из примера
на уникальный токен конкретного агента.
</p>
<code className="mt-3 block break-all rounded-2xl bg-black/20 px-3 py-3 text-12 text-primary">
{CODEX_TOKEN_ENV_VAR}=ndcag_...
</code>
</div>
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">3. Добавьте Ops Agent.md</div>
<p>
Скачайте <code>{OPS_AGENT_FILENAME}</code>. Если в проекте уже есть <code>AGENTS.md</code>, добавьте
содержимое Ops Agent.md в начало текущего файла. Если файла правил нет положите Ops Agent.md в корень
проекта.
</p>
<Button
variant="primary"
size="sm"
className="nodedc-settings-save-button mt-3"
onClick={props.onDownloadAgentsMd}
>
Скачать Ops Agent.md
</Button>
</div>
</div>
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">config.toml block</div>
<textarea
readOnly
className="nodedc-settings-input font-mono h-44 w-full resize-y px-3 py-3 text-12"
value={props.configSnippet}
/>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="primary" size="sm" className="nodedc-settings-save-button" onClick={props.onCopyConfig}>
Скопировать config.toml
</Button>
</div>
</div>
</div>
);
}
function getMcpEndpoint(setup?: TCodexAgentSetupPacket): string {
return setup?.mcp_server?.url ?? DEFAULT_OPS_AGENT_MCP_ENDPOINT;
}
function buildCodexConfigSnippet(endpoint: string): string {
return `[mcp_servers.${CODEX_MCP_SERVER_NAME}]
url = "${endpoint}"
bearer_token_env_var = "${CODEX_TOKEN_ENV_VAR}"
enabled = true
required = true
startup_timeout_sec = 20
tool_timeout_sec = 60`;
}
function buildOpsAgentMarkdown(endpoint: string): string {
return `# NODE.DC Ops Agent Rules
MCP endpoint: ${endpoint}
## Startup
- Call \`tasker_get_agent_instructions\` before creating or changing Tasker cards.
- Call \`tasker_list_projects\` and \`tasker_get_project_context\` before writing into a project.
- Keep Tasker as the source of truth for project cards, checkers, status, labels, comments, and assignments.
## Write Safety
- Every write tool call must include a unique \`idempotency_key\`.
- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.
- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.
- Only assign existing project members returned by project context.
- If a needed label is missing, call \`tasker_ensure_labels\` first and then use returned ids with \`tasker_set_issue_labels\`.
## Card Writing
- Keep card titles concise and operational.
- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.
- Every structured text block must put its visible heading into the block \`title\` field.
- Do not put headings inside block body. Wrong: body starts with \`## Текущая архитектура\`. Correct: \`title: "Текущая архитектура"\`, body contains only content.
- Put short verifiable work items into checker blocks with explicit \`title\` fields.
- After code work, update the related card with factual files touched and validation performed.
## Labels
- Before assigning a new marker/label, check project context labels.
- If the label does not exist, call \`tasker_ensure_labels\` with the granted \`project_id\`.
- Use only label ids returned by project context or \`tasker_ensure_labels\` when calling \`tasker_set_issue_labels\`.
## Effective Grants
- Grants are bound to the local agent token.
- Load effective workspace/project grants through \`tasker_get_agent_instructions\` and \`tasker_list_projects\` after connecting.
- Do not assume access to projects, labels, states, or members that were not returned by NODE.DC MCP tools.
## Available Tools
- \`tasker_get_agent_instructions\`: Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.
- \`tasker_list_projects\`: List Tasker projects granted to the current agent.
- \`tasker_get_project_context\`: Return states, labels, members, and card-writing context for one granted project.
- \`tasker_search_issues\`: Search work items inside one granted Tasker project.
- \`tasker_create_issue\`: Create a Tasker card with optional NODE.DC structured text/checker blocks.
- \`tasker_update_issue\`: Patch allowed issue fields without delete, archive, or project transfer.
- \`tasker_update_structured_blocks\`: Replace NODE.DC structured text/checker blocks in an issue detail layout.
- \`tasker_move_issue\`: Move an issue to an existing state in the same granted project.
- \`tasker_append_comment\`: Append a comment to a granted issue.
- \`tasker_ensure_labels\`: Create missing labels in a granted project and return label ids.
- \`tasker_set_issue_labels\`: Replace issue labels with existing labels from the granted project.
- \`tasker_assign_issue\`: Replace issue assignees with existing members of the granted project.
`;
}
function getAgentDraftName(agentDraftNames: Record<string, string>, agent: TCodexAgent): string {
return agentDraftNames[agent.id] ?? agent.display_name;
}
@ -608,7 +742,7 @@ function maskToken(token: TCodexAgentToken): string {
function getAvatarSrc(avatarUrl?: string | null): string | null {
if (!avatarUrl) return null;
if (/^(data:|blob:|https?:\/\/)/.test(avatarUrl)) return avatarUrl;
return getFileURL(avatarUrl);
return getFileURL(avatarUrl) ?? null;
}
function AgentAvatar(props: { avatarUrl?: string | null; name: string; size?: "md" | "lg" }) {
@ -702,15 +836,73 @@ function readAvatarDataUrl(file: File): Promise<string> {
return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF."));
}
if (file.size > MAX_AGENT_AVATAR_BYTES) {
return Promise.reject(new Error("Аватар агента должен быть не больше 256 КБ."));
if (file.size > MAX_AGENT_AVATAR_SOURCE_BYTES) {
return Promise.reject(new Error("Исходный файл аватара должен быть не больше 50 МБ."));
}
return resizeAvatarFile(file);
}
async function resizeAvatarFile(file: File): Promise<string> {
const objectUrl = URL.createObjectURL(file);
try {
const image = await loadImageElement(objectUrl);
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
if (!sourceWidth || !sourceHeight) {
throw new Error("Не удалось определить размер изображения.");
}
const scale = Math.min(1, AGENT_AVATAR_RENDER_SIZE / Math.max(sourceWidth, sourceHeight));
const width = Math.max(1, Math.round(sourceWidth * scale));
const height = Math.max(1, Math.round(sourceHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Не удалось подготовить аватар.");
}
context.drawImage(image, 0, 0, width, height);
const blob = await canvasToBlob(canvas, "image/webp", AGENT_AVATAR_OUTPUT_QUALITY);
return blobToDataUrl(blob);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function loadImageElement(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image), { once: true });
image.addEventListener("error", () => reject(new Error("Не удалось открыть изображение аватара.")), { once: true });
image.src = src;
});
}
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Не удалось сжать аватар."));
},
type,
quality
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("Не удалось прочитать файл аватара."));
reader.readAsDataURL(file);
reader.addEventListener("load", () => resolve(String(reader.result)), { once: true });
reader.addEventListener("error", () => reject(new Error("Не удалось прочитать файл аватара.")), { once: true });
reader.readAsDataURL(blob);
});
}

View File

@ -11,7 +11,7 @@ import { useTranslation } from "@plane/i18n";
import { InboxIcon, PlusIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import useSWR from "swr";
import { TopNavPowerK } from "@/components/navigation";
import { DeferredTopNavPowerK } from "@/components/navigation/deferred-top-nav-power-k";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
@ -61,7 +61,7 @@ export const SidebarUtilityRail = observer(function SidebarUtilityRail() {
</button>
</Tooltip>
)}
<TopNavPowerK variant="sidebar" />
<DeferredTopNavPowerK variant="sidebar" />
<Tooltip tooltipContent="Уведомления" position="right">
<button
type="button"

View File

@ -75,7 +75,12 @@ const buildIssueStreamUrl = (workspaceSlug: string, projectId: string) => {
return url.toString();
};
export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlug?: string, projectId?: string) => {
export const useIssueRealtimeEvents = (
storeType: EIssuesStoreType,
workspaceSlug?: string,
projectId?: string,
enabled = true
) => {
const { issueMap, issues, issuesFilter } = useIssues(storeType);
const issueServiceRef = useRef(new IssueService());
const issueMapRef = useRef(issueMap);
@ -98,7 +103,8 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
}, [issuesFilter]);
useEffect(() => {
if (!workspaceSlug || !projectId || !REALTIME_STORE_TYPES.has(storeType) || typeof window === "undefined") return;
if (!enabled || !workspaceSlug || !projectId || !REALTIME_STORE_TYPES.has(storeType) || typeof window === "undefined")
return;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
@ -280,5 +286,5 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
if (reconnectTimer) clearTimeout(reconnectTimer);
socket?.close();
};
}, [storeType, workspaceSlug, projectId]);
}, [enabled, storeType, workspaceSlug, projectId]);
};

View File

@ -17,6 +17,7 @@ import { useTimeLineType } from "../components/gantt-chart/contexts";
export const useTimeLineChart = (timelineType: TTimelineType): IBaseTimelineStore => {
const context = useContext(StoreContext);
if (!context) throw new Error("useTimeLineChart must be used within StoreProvider");
if (!context.timelineStore) throw context.loadTimelineStore();
return getTimelineStore(context.timelineStore, timelineType);
};
@ -27,6 +28,7 @@ export const useTimeLineChartStore = (): IBaseTimelineStore => {
if (!context) throw new Error("useTimeLineChartStore must be used within StoreProvider");
if (!timelineType) throw new Error("useTimeLineChartStore must be used within TimeLineTypeContext");
if (!context.timelineStore) throw context.loadTimelineStore();
return getTimelineStore(context.timelineStore, timelineType);
};

View File

@ -5,12 +5,11 @@
*/
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { GANTT_TIMELINE_TYPE } from "@plane/types";
// components
import { ProjectAccessRestriction } from "@/components/auth-screens/project/project-access-restriction";
import {
@ -36,7 +35,6 @@ import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useTimeLineChart } from "@/hooks/use-timeline-chart";
interface IProjectAuthWrapper {
workspaceSlug: string;
@ -55,7 +53,6 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
const { joinProject } = useUserPermissions();
const { fetchAllCycles } = useCycle();
const { fetchModulesSlim, fetchModules } = useModule();
const { initGantt } = useTimeLineChart(GANTT_TIMELINE_TYPE.MODULE);
const { fetchViews } = useProjectView();
const {
project: { fetchProjectMembers, fetchProjectUserProperties },
@ -73,12 +70,6 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
);
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE, workspaceSlug);
// Initialize module timeline chart
useEffect(() => {
initGantt();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// fetching project details
const { isLoading: isProjectDetailsLoading, error: projectDetailsError } = useSWR(
PROJECT_DETAILS(workspaceSlug, projectId),

View File

@ -11,7 +11,7 @@ import { v4 as uuidv4 } from "uuid";
// plane types
import type { EFileAssetType, TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
// services
import { FileService } from "@/services/file.service";
import type { FileService } from "@/services/file.service";
import type { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store";
export interface IEditorAssetStore {
@ -52,7 +52,7 @@ export class EditorAssetStore implements IEditorAssetStore {
// observables
assetsUploadStatus: Record<string, TAttachmentUploadStatus> = {};
// services
fileService: FileService;
private fileService: FileService | undefined;
constructor() {
makeObservable(this, {
@ -63,8 +63,6 @@ export class EditorAssetStore implements IEditorAssetStore {
// actions
uploadEditorAsset: action,
});
// services
this.fileService = new FileService();
}
get assetsUploadPercentage() {
@ -93,9 +91,19 @@ export class EditorAssetStore implements IEditorAssetStore {
});
}, 16);
private getFileService = async () => {
if (!this.fileService) {
const { FileService } = await import("@/services/file.service");
this.fileService = new FileService();
}
return this.fileService;
};
uploadEditorAsset: IEditorAssetStore["uploadEditorAsset"] = async (args) => {
const { blockId, data, file, projectId, workspaceSlug } = args;
const tempId = uuidv4();
const fileService = await this.getFileService();
try {
// update attachment upload status
@ -109,7 +117,7 @@ export class EditorAssetStore implements IEditorAssetStore {
});
});
if (projectId) {
const response = await this.fileService.uploadProjectAsset(
const response = await fileService.uploadProjectAsset(
workspaceSlug,
projectId,
data,
@ -121,7 +129,7 @@ export class EditorAssetStore implements IEditorAssetStore {
);
return response;
} else {
const response = await this.fileService.uploadWorkspaceAsset(workspaceSlug, data, file, (progressEvent) => {
const response = await fileService.uploadWorkspaceAsset(workspaceSlug, data, file, (progressEvent) => {
const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
this.debouncedUpdateProgress(blockId, progressPercentage);
});
@ -138,7 +146,8 @@ export class EditorAssetStore implements IEditorAssetStore {
};
duplicateEditorAsset: IEditorAssetStore["duplicateEditorAsset"] = async (args) => {
const { assetId, entityId, entityType, projectId, workspaceSlug } = args;
const { asset_id } = await this.fileService.duplicateAsset(workspaceSlug, assetId, {
const fileService = await this.getFileService();
const { asset_id } = await fileService.duplicateAsset(workspaceSlug, assetId, {
entity_id: entityId,
entity_type: entityType,
project_id: projectId,

View File

@ -11,7 +11,7 @@ import { v4 as uuidv4 } from "uuid";
// types
import type { TIssueAttachment, TIssueAttachmentMap, TIssueAttachmentIdMap, TIssueServiceType } from "@plane/types";
// services
import { IssueAttachmentService } from "@/services/issue";
import type { IssueAttachmentService } from "@/services/issue/issue_attachment.service";
import type { IIssueRootStore } from "../root.store";
import type { IIssueDetail } from "./root.store";
@ -75,7 +75,8 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
rootIssueStore: IIssueRootStore;
rootIssueDetailStore: IIssueDetail;
// services
issueAttachmentService;
private issueAttachmentService: IssueAttachmentService | undefined;
private serviceType: TIssueServiceType;
constructor(rootStore: IIssueRootStore, serviceType: TIssueServiceType) {
makeObservable(this, {
@ -94,10 +95,18 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
// root store
this.rootIssueStore = rootStore;
this.rootIssueDetailStore = rootStore.issueDetail;
// services
this.issueAttachmentService = new IssueAttachmentService(serviceType);
this.serviceType = serviceType;
}
private getIssueAttachmentService = async () => {
if (!this.issueAttachmentService) {
const { IssueAttachmentService } = await import("@/services/issue/issue_attachment.service");
this.issueAttachmentService = new IssueAttachmentService(this.serviceType);
}
return this.issueAttachmentService;
};
// computed
get issueAttachments() {
const issueId = this.rootIssueDetailStore.peekIssue?.issueId;
@ -143,7 +152,8 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
};
fetchAttachments = async (workspaceSlug: string, projectId: string, issueId: string) => {
const response = await this.issueAttachmentService.getIssueAttachments(workspaceSlug, projectId, issueId);
const issueAttachmentService = await this.getIssueAttachmentService();
const response = await issueAttachmentService.getIssueAttachments(workspaceSlug, projectId, issueId);
runInAction(() => {
response.forEach((attachment) => set(this.attachmentMap, attachment.id, attachment));
set(
@ -166,6 +176,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
createAttachment = async (workspaceSlug: string, projectId: string, issueId: string, file: File) => {
const tempId = uuidv4();
const issueAttachmentService = await this.getIssueAttachmentService();
try {
// update attachment upload status
runInAction(() => {
@ -177,7 +188,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
type: file.type,
});
});
const response = await this.issueAttachmentService.uploadIssueAttachment(
const response = await issueAttachmentService.uploadIssueAttachment(
workspaceSlug,
projectId,
issueId,
@ -220,7 +231,8 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
};
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
const response = await this.issueAttachmentService.deleteIssueAttachment(
const issueAttachmentService = await this.getIssueAttachmentService();
const response = await issueAttachmentService.deleteIssueAttachment(
workspaceSlug,
projectId,
issueId,

View File

@ -7,7 +7,7 @@
import { action, observable, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { APITokenService } from "@plane/services";
import type { APITokenService } from "@plane/services";
import type { IApiToken } from "@plane/types";
// services
// store
@ -30,7 +30,7 @@ export class ApiTokenStore implements IApiTokenStore {
// observables
apiTokens: Record<string, IApiToken> | null = null;
// services
apiTokenService;
private apiTokenService: APITokenService | undefined;
// root store
rootStore;
@ -47,10 +47,17 @@ export class ApiTokenStore implements IApiTokenStore {
});
// root store
this.rootStore = _rootStore;
// services
this.apiTokenService = new APITokenService();
}
private getApiTokenService = async () => {
if (!this.apiTokenService) {
const { APITokenService } = await import("@plane/services");
this.apiTokenService = new APITokenService();
}
return this.apiTokenService;
};
/**
* get API token by id
* @param apiTokenId
@ -63,54 +70,58 @@ export class ApiTokenStore implements IApiTokenStore {
/**
* fetch all the API tokens
*/
fetchApiTokens = async () =>
await this.apiTokenService.list().then((response) => {
const apiTokensObject: { [apiTokenId: string]: IApiToken } = response.reduce((accumulator, currentWebhook) => {
if (currentWebhook && currentWebhook.id) {
return { ...accumulator, [currentWebhook.id]: currentWebhook };
}
return accumulator;
}, {});
runInAction(() => {
this.apiTokens = apiTokensObject;
});
return response;
fetchApiTokens = async () => {
const apiTokenService = await this.getApiTokenService();
const response = await apiTokenService.list();
const apiTokensObject = response.reduce<Record<string, IApiToken>>((accumulator, currentWebhook) => {
if (currentWebhook && currentWebhook.id) {
accumulator[currentWebhook.id] = currentWebhook;
}
return accumulator;
}, {});
runInAction(() => {
this.apiTokens = apiTokensObject;
});
return response;
};
/**
* fetch API token details using token id
* @param tokenId
*/
fetchApiTokenDetails = async (tokenId: string) =>
await this.apiTokenService.retrieve(tokenId).then((response) => {
runInAction(() => {
this.apiTokens = { ...this.apiTokens, [response.id]: response };
});
return response;
fetchApiTokenDetails = async (tokenId: string) => {
const apiTokenService = await this.getApiTokenService();
const response = await apiTokenService.retrieve(tokenId);
runInAction(() => {
this.apiTokens = { ...this.apiTokens, [response.id]: response };
});
return response;
};
/**
* create API token using data
* @param data
*/
createApiToken = async (data: Partial<IApiToken>) =>
await this.apiTokenService.create(data).then((response) => {
runInAction(() => {
this.apiTokens = { ...this.apiTokens, [response.id]: response };
});
return response;
createApiToken = async (data: Partial<IApiToken>) => {
const apiTokenService = await this.getApiTokenService();
const response = await apiTokenService.create(data);
runInAction(() => {
this.apiTokens = { ...this.apiTokens, [response.id]: response };
});
return response;
};
/**
* delete API token using token id
* @param tokenId
*/
deleteApiToken = async (tokenId: string) =>
await this.apiTokenService.destroy(tokenId).then(() => {
const updatedApiTokens = { ...this.apiTokens };
delete updatedApiTokens[tokenId];
runInAction(() => {
this.apiTokens = updatedApiTokens;
});
deleteApiToken = async (tokenId: string) => {
const apiTokenService = await this.getApiTokenService();
await apiTokenService.destroy(tokenId);
const updatedApiTokens = { ...this.apiTokens };
delete updatedApiTokens[tokenId];
runInAction(() => {
this.apiTokens = updatedApiTokens;
});
};
}

View File

@ -9,6 +9,28 @@ http {
default_type application/octet-stream;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
application/javascript
application/json
application/manifest+json
application/rss+xml
application/vnd.ms-fontobject
application/wasm
application/x-javascript
application/xml
font/ttf
font/otf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
set_real_ip_from 0.0.0.0/0;
real_ip_recursive on;
real_ip_header X-Forward-For;

View File

@ -19,7 +19,8 @@ BUILD_ADMIN=${BUILD_ADMIN:-0}
BACKEND_BUILDKIT=${BACKEND_BUILDKIT:-0}
WEB_BUILDKIT=${WEB_BUILDKIT:-0}
ADMIN_BUILDKIT=${ADMIN_BUILDKIT:-1}
ADMIN_BUILDKIT=${ADMIN_BUILDKIT:-0}
ADMIN_REQUIRED=${ADMIN_REQUIRED:-0}
PLANE_BACKEND_IMAGE=${PLANE_BACKEND_IMAGE:-nodedc/plane-backend:local}
PLANE_FRONTEND_IMAGE=${PLANE_FRONTEND_IMAGE:-nodedc/plane-frontend:ru}
@ -106,11 +107,19 @@ fi
if [ "$BUILD_ADMIN" = "1" ]; then
printf "== admin image: %s ==\n" "$PLANE_ADMIN_IMAGE"
cd "$SRC"
run_env DOCKER_BUILDKIT="$ADMIN_BUILDKIT" "$DOCKER" build \
if run_env DOCKER_BUILDKIT="$ADMIN_BUILDKIT" "$DOCKER" build \
--build-arg "VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL" \
-t "$PLANE_ADMIN_IMAGE" \
-f apps/admin/Dockerfile.admin .
RECREATE_SERVICES="$RECREATE_SERVICES admin"
-f apps/admin/Dockerfile.admin .; then
RECREATE_SERVICES="$RECREATE_SERVICES admin"
else
admin_status=$?
if [ "$ADMIN_REQUIRED" = "1" ] || [ -z "$RECREATE_SERVICES" ]; then
printf "!! admin image build failed (exit %s); aborting\n" "$admin_status" >&2
exit "$admin_status"
fi
printf "!! admin image build failed (exit %s); continuing with:%s\n" "$admin_status" "$RECREATE_SERVICES" >&2
fi
else
printf "== skip admin image ==\n"
fi