diff --git a/docs_prod/cross-project-task-routing/README.md b/docs_prod/cross-project-task-routing/README.md index 447a085..29e8829 100644 --- a/docs_prod/cross-project-task-routing/README.md +++ b/docs_prod/cross-project-task-routing/README.md @@ -81,12 +81,56 @@ - source-side список отправленных запросов - source-side детальный экран на базе shell `Предложений` - status pill по фактическому состоянию target issue -- чтение и редактирование title/description/priority/due date/assignees/labels через target issue API +- workspace-wide выбор целевого внешнего контура по policy: + - тот же `workspace` + - у целевого проекта включен модуль + - прямой membership в target project не требуется +- source-side карточка как основная точка работы отправителя, если у него нет доступа в целевой проект +- source-side редактирование открытого запроса: + - `заголовок` + - `описание` +- source-side действия: + - `Принять` + - `Отклонить` + - `Ответ во внешний контур` +- зеркалирование из целевой задачи в source-side карточку: + - комментарии + - вложения + - activity + - обновления статуса +- proxy download для вложений без прямого membership в target project +- unread-индикатор новых изменений по source-side карточке +- блок `Маршрутизация` в фиксированном формате `3 x 3` -Текущее ограничение MVP: -- выбор `Внешнего контура` сейчас доступен только среди проектов, в которых отправитель уже состоит -- это осознанное упрощение первого рабочего вертикального среза -- за счет этого source-side карточка может безопасно использовать обычные target issue API без отдельного proxy-слоя для комментариев и файлов +## Freeze point на 2026-04-19 + +Текущий этап временно заморожен. + +Заморозка делается после достижения рабочего вертикального среза: +- запрос можно отправить из проекта-источника в другой проект того же `workspace` +- в целевом проекте создается обычная задача и сразу попадает в обычный workflow +- отправитель видит свой source-side список и карточку без обязательного доступа в чужой проект +- в source-side карточке видны статус, маршрут, назначенный, срок, комментарии, вложения и activity из целевого контура +- отправитель может поправить ошибку в `заголовке` и `описании`, пока запрос открыт +- отправитель может принять результат во внутренний контур или вернуть запрос обратно во внешний контур с комментарием причины + +На этом месте разработка следующего шага останавливается до фиксации продуктовых решений. + +## Что нужно решить перед продолжением + +- Что именно делает действие `Принять`: + - только фиксирует решение источника + - создает отдельную сущность во `Внутреннем контуре` + - или переводит внешний запрос в отдельный внутренний режим без дублирования сущностей +- Должен ли принятый запрос оставаться во `Внешних контурах` как историческая карточка, или он должен исчезать из рабочего списка и жить только во `Внутреннем контуре` +- Нужен ли отдельный статус или отдельная вкладка для запросов, которые уже приняты во `Внутренний контур` +- Должна ли коммуникация по комментариям быть полностью двусторонней, или source-side ответов достаточно только для возврата и уточнений +- Нужно ли физически копировать файлы в source-side представление, или для PoC достаточно proxy-доступа к файлам целевой задачи +- Нужно ли переводить обновление карточки с polling на realtime/push уже в следующем этапе, или polling пока приемлем +- Нужны ли отдельные счетчики непрочитанных изменений по вкладкам `Открытые` и `Завершенные` +- Должен ли отправитель после создания запроса иметь право менять только `заголовок` и `описание`, или еще и `назначенного`, `срок`, `приоритет`, `метки` +- Нужно ли сохранять запрет на прямой переход в целевую задачу для пользователей без membership в target project как постоянное правило +- Какой финальный lifecycle должен быть у запроса после возврата, принятия, завершения и отмены, чтобы source-side карточка не стала второй несогласованной системой учета ## Обязательные требования diff --git a/docs_prod/cross-project-task-routing/phase-roadmap.md b/docs_prod/cross-project-task-routing/phase-roadmap.md index fc8db4d..cbe5275 100644 --- a/docs_prod/cross-project-task-routing/phase-roadmap.md +++ b/docs_prod/cross-project-task-routing/phase-roadmap.md @@ -10,6 +10,23 @@ 3. потом синхронизация 4. потом уведомления и полировка +## Freeze point на 2026-04-19 + +Текущий вертикальный срез временно заморожен. + +На момент freeze point уже есть: +- отправка запроса из source project в target project того же `workspace` +- отсутствие требования прямого membership в target project для отправки +- source-side список `Открытые / Завершенные` +- source-side карточка на shell `Предложений` +- source-side редактирование открытого запроса по `заголовку` и `описанию` +- зеркалирование статуса, комментариев, вложений и activity из целевого контура +- source-side действия `Принять`, `Отклонить`, `Ответ во внешний контур` +- индикатор непрочитанных изменений +- карточка `Маршрутизация` в целевом формате `3 x 3` + +Дальше по roadmap пока не идем, пока не приняты продуктовые решения по внутреннему жизненному циклу принятого запроса. + ## Этап 0. Термины и навигация ### Цель @@ -242,35 +259,64 @@ ## Открытые вопросы -### 1. Source-side сущность +### 1. Что именно делает `Принять` + +Нужно зафиксировать конечную бизнес-логику: +- `Принять` только ставит source-side решение `accepted` +- `Принять` создает отдельную сущность во `Внутреннем контуре` +- `Принять` переводит карточку в отдельный внутренний статус без создания дубликата + +Это главный блокирующий вопрос перед следующим этапом. + +### 2. Где дальше живет принятый запрос + +Нужно решить: +- карточка остается во `Внешних контурах` как историческая запись +- карточка уходит во `Внутренний контур` +- карточка одновременно видна в обоих местах, но с разной ролью + +### 3. Нужна ли отдельная сущность source-side Нужно принять решение: -- достаточно ли source-side проекции -- или нужна отдельная таблица/модель для внешних контуров +- достаточно ли текущей source-side проекции поверх bridge metadata +- или уже пора вводить отдельную таблицу/модель для внешних контуров -### 2. Файлы +### 4. Файлы Нужно решить: -- показываем ли мы source-side ссылку на target asset -- или физически копируем файл в source representation +- достаточно ли proxy-доступа к файлам целевой задачи +- или файлы надо физически копировать в source-side representation +- нужно ли зеркалировать inline-файлы из описания и комментариев -### 3. Комментарии +### 5. Комментарии Нужно решить: -- комментарии зеркалируются односторонне из цели в источник -- или источник тоже может отвечать прямо из source-side карточки +- source-side reply остается облегченной обратной связью +- или нужен полноценный двусторонний поток комментариев как единый discussion-thread -### 4. Уровень realtime +### 6. Уровень realtime Нужно решить: -- хватит ли near-realtime через polling и existing refresh -- или сразу нужен realtime через live-события +- хватает ли polling для PoC +- или следующий этап уже должен включать push/realtime события -### 5. Доступ к target issue +### 7. Счетчики и вкладки + +Нужно решить: +- нужен ли отдельный unread-counter по вкладкам `Открытые / Завершенные` +- нужен ли отдельный сегмент для запросов, принятых во `Внутренний контур` + +### 8. Право редактирования после отправки + +Нужно решить: +- отправитель редактирует только `заголовок` и `описание` +- или после отправки он может менять еще `срок`, `назначенного`, `приоритет`, `метки` + +### 9. Доступ к target issue Нужно решить: - должен ли инициатор иметь прямую ссылку на target issue -- или доступ к target issue должен быть скрыт, а source-side карточка должна быть единственной точкой просмотра +- или source-side карточка должна оставаться единственной точкой просмотра для пользователей без membership Текущее решение: - при отсутствии membership в target project прямой переход в target issue скрывается diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx index 5279362..15dc5ce 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -8,13 +8,14 @@ import { isEmpty } from "lodash-es"; import { observer } from "mobx-react"; // plane helpers import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; // components import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper"; import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list"; import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions"; import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; +import { SidebarUtilityRail } from "@/components/workspace/sidebar/sidebar-utility-rail"; +import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root"; // hooks import { useFavorite } from "@/hooks/store/use-favorite"; import { useUserPermissions } from "@/hooks/store/user"; @@ -22,7 +23,6 @@ import { useUserPermissions } from "@/hooks/store/user"; import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list"; export const AppSidebar = observer(function AppSidebar() { - const { t } = useTranslation(); // store hooks const { allowPermissions } = useUserPermissions(); const { groupedFavorites } = useFavorite(); @@ -36,7 +36,11 @@ export const AppSidebar = observer(function AppSidebar() { const isFavoriteEmpty = isEmpty(groupedFavorites); return ( - }> + } + quickActions={} + footer={} + > {/* Favorites Menu */} {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } diff --git a/plane-src/apps/web/ce/components/navigations/top-navigation-root.tsx b/plane-src/apps/web/ce/components/navigations/top-navigation-root.tsx index 5a81d58..2322cb7 100644 --- a/plane-src/apps/web/ce/components/navigations/top-navigation-root.tsx +++ b/plane-src/apps/web/ce/components/navigations/top-navigation-root.tsx @@ -12,6 +12,7 @@ import { TopNavPowerK } from "@/components/navigation"; 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"; +import { usePlatformOS } from "@/hooks/use-platform-os"; import { Tooltip } from "@plane/propel/tooltip"; import { AppSidebarItem } from "@/components/sidebar/sidebar-item"; import { InboxIcon } from "@plane/propel/icons"; @@ -26,6 +27,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() { // store hooks const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications(); const { preferences } = useAppRailPreferences(); + const { isMobile } = usePlatformOS(); const showLabel = preferences.displayMode === "icon_with_label"; @@ -41,6 +43,8 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() { ? unreadNotificationsCount.mention_unread_notifications_count : unreadNotificationsCount.total_unread_notifications_count; + if (!isMobile) return null; + return (
-
+
+
- {label} + {label} {appendElement}
-
{children}
+
+ {children} +
); } diff --git a/plane-src/apps/web/core/components/issues/issue-detail/label/select/label-select.tsx b/plane-src/apps/web/core/components/issues/issue-detail/label/select/label-select.tsx index e44e27d..6a44b56 100644 --- a/plane-src/apps/web/core/components/issues/issue-detail/label/select/label-select.tsx +++ b/plane-src/apps/web/core/components/issues/issue-detail/label/select/label-select.tsx @@ -12,8 +12,7 @@ import { Combobox } from "@headlessui/react"; // plane imports import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/propel/button"; -import { CheckIcon, SearchIcon, PlusIcon } from "@plane/propel/icons"; +import { CheckIcon, SearchIcon } from "@plane/propel/icons"; import type { IIssueLabel } from "@plane/types"; import { EUserProjectRoles } from "@plane/types"; // helpers @@ -92,7 +91,7 @@ export const IssueLabelSelect = observer(function IssueLabelSelect(props: IIssue const issueLabels = values ?? []; - const label = {t("label.select")}; + const label = {t("label.select")}; const searchInputKeyDown = async (e: React.KeyboardEvent) => { if (query !== "" && e.key === "Escape") { @@ -121,22 +120,20 @@ export const IssueLabelSelect = observer(function IssueLabelSelect(props: IIssue <> onSelect(value)} multiple > - + diff --git a/plane-src/apps/web/core/components/issues/issue-detail/parent-select.tsx b/plane-src/apps/web/core/components/issues/issue-detail/parent-select.tsx index 192f7b1..4acbd33 100644 --- a/plane-src/apps/web/core/components/issues/issue-detail/parent-select.tsx +++ b/plane-src/apps/web/core/components/issues/issue-detail/parent-select.tsx @@ -121,7 +121,7 @@ export const IssueParentSelect = observer(function IssueParentSelect(props: TIss )}
) : ( - {t("issue.add.parent")} + {t("issue.add.parent")} )} {!disabled && ( diff --git a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx index 025ddfd..5b5fff5 100644 --- a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx +++ b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx @@ -4,7 +4,7 @@ * See the LICENSE file for details. */ -import { useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; import { observer } from "mobx-react"; import { createPortal } from "react-dom"; // plane imports @@ -27,6 +27,8 @@ import { PeekOverviewIssueDetails } from "./issue-detail"; import { IssuePeekOverviewLoader } from "./loader"; import { PeekOverviewProperties } from "./properties"; +const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width"; + interface IIssueView { workspaceSlug: string; projectId: string; @@ -60,9 +62,21 @@ export const IssueView = observer(function IssueView(props: IIssueView) { const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false); const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false); const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false); + const [sidePeekWidth, setSidePeekWidth] = useState(() => { + if (typeof window === "undefined") return 720; + + const fallbackWidth = Math.max(640, Math.floor(window.innerWidth * 0.5)); + const storedWidth = window.localStorage.getItem(SIDE_PEEK_WIDTH_STORAGE_KEY); + const parsedWidth = storedWidth ? parseInt(storedWidth, 10) : NaN; + + return Number.isFinite(parsedWidth) ? parsedWidth : fallbackWidth; + }); + const [isResizingPeek, setIsResizingPeek] = useState(false); // ref const issuePeekOverviewRef = useRef(null); const editorRef = useRef(null); + const initialPeekWidthRef = useRef(0); + const initialMouseXRef = useRef(0); // store hooks const { setPeekIssue, @@ -85,6 +99,77 @@ export const IssueView = observer(function IssueView(props: IIssueView) { const isAnyLocalModalOpen = isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen; + const stopPeekResizing = useCallback(() => { + setIsResizingPeek(false); + }, []); + + const handlePeekResize = useCallback( + (event: MouseEvent) => { + if (!isResizingPeek) return; + + const maxWidth = Math.max(720, window.innerWidth - 48); + const minWidth = 640; + const deltaX = event.clientX - initialMouseXRef.current; + const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth); + setSidePeekWidth(nextWidth); + }, + [isResizingPeek] + ); + + const startPeekResizing = useCallback( + (event: ReactMouseEvent) => { + if (peekMode !== "side-peek") return; + event.preventDefault(); + setIsResizingPeek(true); + initialPeekWidthRef.current = sidePeekWidth; + initialMouseXRef.current = event.clientX; + }, + [peekMode, sidePeekWidth] + ); + + useEffect(() => { + const handleWindowResize = () => { + const maxWidth = Math.max(720, window.innerWidth - 48); + setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth)); + }; + + window.addEventListener("resize", handleWindowResize); + return () => window.removeEventListener("resize", handleWindowResize); + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + + const maxWidth = Math.max(720, window.innerWidth - 48); + const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth); + + window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth)); + + if (clampedWidth !== sidePeekWidth) { + setSidePeekWidth(clampedWidth); + } + }, [sidePeekWidth]); + + useEffect(() => { + if (!isResizingPeek) return; + + document.addEventListener("mousemove", handlePeekResize); + document.addEventListener("mouseup", stopPeekResizing); + document.addEventListener("mouseleave", stopPeekResizing); + window.addEventListener("blur", stopPeekResizing); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + return () => { + document.removeEventListener("mousemove", handlePeekResize); + document.removeEventListener("mouseup", stopPeekResizing); + document.removeEventListener("mouseleave", stopPeekResizing); + window.removeEventListener("blur", stopPeekResizing); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [handlePeekResize, isResizingPeek, stopPeekResizing]); + usePeekOverviewOutsideClickDetector( issuePeekOverviewRef, () => { @@ -123,7 +208,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) { ? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300" : `h-full w-full`, !embedIssue && { - "top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] resize-x rounded-[28px] border md:w-[50%] md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]": + "top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]": peekMode === "side-peek", "top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal", "absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen", @@ -141,10 +226,19 @@ export const IssueView = observer(function IssueView(props: IIssueView) { ref={issuePeekOverviewRef} className={peekOverviewIssueClassName} style={{ + width: !embedIssue && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined, boxShadow: "0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)", }} > + {!embedIssue && peekMode === "side-peek" && ( +
+ )} {isError ? (
diff --git a/plane-src/apps/web/core/components/navigation/top-nav-power-k.tsx b/plane-src/apps/web/core/components/navigation/top-nav-power-k.tsx index 22825d4..298bd26 100644 --- a/plane-src/apps/web/core/components/navigation/top-nav-power-k.tsx +++ b/plane-src/apps/web/core/components/navigation/top-nav-power-k.tsx @@ -21,7 +21,12 @@ import { useUser } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; import { useExpandableSearch } from "@/hooks/use-expandable-search"; -export const TopNavPowerK = observer(() => { +type TTopNavPowerKProps = { + variant?: "top-navigation" | "sidebar"; +}; + +export const TopNavPowerK = observer((props: TTopNavPowerKProps) => { + const { variant = "top-navigation" } = props; // router const router = useAppRouter(); const params = useParams(); @@ -208,49 +213,111 @@ export const TopNavPowerK = observer(() => { return (
-
+ {variant === "top-navigation" ? (
inputRef.current?.focus()} - role="button" + className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", { + "w-[554px]": isOpen, + })} > - - { - setSearchTerm(e.target.value); - if (!isOpen) openPanel(); - }} - onMouseDown={handleMouseDown} - onFocus={handleFocus} - onKeyDown={handleKeyDown} - placeholder="Search commands..." - className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none" - /> - {searchTerm && ( - - )} +
inputRef.current?.focus()} + role="button" + > + + { + setSearchTerm(e.target.value); + if (!isOpen) openPanel(); + }} + onMouseDown={handleMouseDown} + onFocus={handleFocus} + onKeyDown={handleKeyDown} + placeholder="Search commands..." + className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none" + /> + {searchTerm && ( + + )} +
-
+ ) : ( +
+ +
+ { + setSearchTerm(e.target.value); + if (!isOpen) openPanel(); + }} + onMouseDown={handleMouseDown} + onFocus={handleFocus} + onKeyDown={handleKeyDown} + placeholder="Search commands..." + className={cn( + "placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none transition-all", + { + "pointer-events-none w-0 opacity-0": !isOpen, + } + )} + /> + {isOpen && searchTerm && ( + + )} +
+
+ )}
diff --git a/plane-src/apps/web/core/components/sidebar/resizable-sidebar.tsx b/plane-src/apps/web/core/components/sidebar/resizable-sidebar.tsx index 5db352d..12f5dc8 100644 --- a/plane-src/apps/web/core/components/sidebar/resizable-sidebar.tsx +++ b/plane-src/apps/web/core/components/sidebar/resizable-sidebar.tsx @@ -180,7 +180,7 @@ export function ResizableSidebar({