From b814bd0f12df5912a3d8b62eb57e92a3aa9b2fc5 Mon Sep 17 00:00:00 2001 From: Salihu Date: Fri, 21 Aug 2026 19:29:10 +0100 Subject: [PATCH 1/5] fix: checkbox filtering --- apps/server/src/ee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index e13af0ce0..6dfbcb924 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit e13af0ce055cdc83962533f0a7865ad60a11e55e +Subproject commit 6dfbcb92412008e612c89800125d27c58da2bae5 From b7f0b063dc576015482e7aed20357ee014626afa Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:29:21 +0100 Subject: [PATCH 2/5] feat(ee): add enterprise ai chat controls (#2421) * feat(ee): add enterprise ai chat controls * feat: add comment listing tool to ai chat * fix: scope comment endpoints to workspace and skip trashed pages --- .../public/locales/de-DE/translation.json | 2 +- .../public/locales/en-US/translation.json | 10 ++- .../public/locales/es-ES/translation.json | 2 +- .../public/locales/fr-FR/translation.json | 2 +- .../public/locales/it-IT/translation.json | 2 +- .../public/locales/ja-JP/translation.json | 2 +- .../public/locales/ko-KR/translation.json | 2 +- .../public/locales/nl-NL/translation.json | 2 +- .../public/locales/pt-BR/translation.json | 2 +- .../public/locales/ru-RU/translation.json | 2 +- .../public/locales/uk-UA/translation.json | 2 +- .../public/locales/zh-CN/translation.json | 2 +- .../ai-chat/components/ai-chat-read-only.tsx | 74 +++++++++++++++++++ .../ai-chat-workspace-knowledge-only.tsx | 74 +++++++++++++++++++ .../ai-chat/components/chat-empty-state.tsx | 17 ++++- .../src/ee/ai-chat/components/chat-input.tsx | 35 ++++++++- apps/client/src/ee/ai/pages/ai-settings.tsx | 24 +++++- apps/client/src/ee/features.ts | 1 + .../workspace/types/workspace.types.ts | 4 + apps/server/src/common/features.ts | 1 + .../src/core/comment/comment.controller.ts | 17 +++-- .../workspace/dto/update-workspace.dto.ts | 8 ++ .../workspace/services/workspace.service.ts | 49 +++++++++++- apps/server/src/ee | 2 +- 24 files changed, 308 insertions(+), 30 deletions(-) create mode 100644 apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx create mode 100644 apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index f067ecc27..084c92ef5 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Upgrade Ihres Plans", "Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz", "Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "KI ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "KI ist in den kostenpflichtigen Docmost-Editionen verfügbar. Kontaktieren Sie sales@docmost.com.", "AI & MCP": "KI & MCP", "AI": "KI", "MCP": "MCP", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 03e10c53e..e5768c7e4 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -698,7 +698,7 @@ "Upgrade your plan": "Upgrade your plan", "Available with a paid license": "Available with a paid license", "Upgrade your license tier.": "Upgrade your license tier.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is available in the Docmost paid editions. Contact sales@docmost.com.", "AI & MCP": "AI & MCP", "AI": "AI", "MCP": "MCP", @@ -1302,5 +1302,11 @@ "Error loading attachments.": "Error loading attachments.", "No attachments on this page yet.": "No attachments on this page yet.", "Uploaded by {{name}}": "Uploaded by {{name}}", - "Download {{name}}": "Download {{name}}" + "Download {{name}}": "Download {{name}}", + "Workspace knowledge only": "Workspace knowledge only", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.", + "Toggle workspace knowledge only": "Toggle workspace knowledge only", + "Read-only mode": "Read-only mode", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.", + "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode" } diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index fd834a2c6..fb5364725 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Mejora tu plan", "Available with a paid license": "Disponible con una licencia de pago", "Upgrade your license tier.": "Mejora el nivel de tu licencia.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "La IA solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "La IA está disponible en las ediciones de pago de Docmost. Contacte con sales@docmost.com.", "AI & MCP": "IA y MCP", "AI": "IA", "MCP": "MCP", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 04ebbc6ad..e9fbeed06 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Mettez à niveau votre forfait", "Available with a paid license": "Disponible avec une licence payante", "Upgrade your license tier.": "Mettez à niveau votre niveau de licence.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA est disponible dans les éditions payantes de Docmost. Contactez sales@docmost.com.", "AI & MCP": "IA & MCP", "AI": "IA", "MCP": "MCP", diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index e3aaa82ce..81eb1727f 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Aggiorna il tuo piano", "Available with a paid license": "Disponibile con una licenza a pagamento", "Upgrade your license tier.": "Aggiorna il livello della tua licenza.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA è disponibile nelle edizioni a pagamento di Docmost. Contatta sales@docmost.com.", "AI & MCP": "IA e MCP", "AI": "IA", "MCP": "MCP", diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index e408bfda8..c1bfa1652 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "プランをアップグレードする", "Available with a paid license": "有料ライセンスで利用可能", "Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI は Docmost の有料版で利用可能です。sales@docmost.com までお問い合わせください。", "AI & MCP": "AI と MCP", "AI": "AI", "MCP": "MCP", diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index fb8e9884a..a7d795454 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "요금제를 업그레이드하세요", "Available with a paid license": "유료 라이선스에서만 사용 가능합니다", "Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI는 Docmost 유료 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.", "AI & MCP": "AI 및 MCP", "AI": "AI", "MCP": "MCP", diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 59c1d7a73..1f1465ab1 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Upgrade je abonnement", "Available with a paid license": "Beschikbaar met een betaalde licentie", "Upgrade your license tier.": "Upgrade je licentieniveau.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is beschikbaar in de betaalde edities van Docmost. Neem contact op met sales@docmost.com.", "AI & MCP": "AI & MCP", "AI": "AI", "MCP": "MCP", diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 2e4cd89ef..82f213703 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Faça upgrade do seu plano", "Available with a paid license": "Disponível com uma licença paga", "Upgrade your license tier.": "Faça upgrade do seu nível de licença.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "A IA está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "A IA está disponível nas edições pagas do Docmost. Contate sales@docmost.com.", "AI & MCP": "IA e MCP", "AI": "IA", "MCP": "MCP", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index bae44d133..9d4ddc8a6 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Обновите свой тарифный план", "Available with a paid license": "Доступно с платной лицензией", "Upgrade your license tier.": "Обновите уровень вашей лицензии.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ИИ доступен в платных версиях Docmost. Свяжитесь по адресу sales@docmost.com.", "AI & MCP": "ИИ и MCP", "AI": "ИИ", "MCP": "MCP", diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index d3dd687ea..be09a646f 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Оновіть свій тарифний план", "Available with a paid license": "Доступно за платною ліцензією", "Upgrade your license tier.": "Оновіть рівень своєї ліцензії.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ШІ доступний у платних редакціях Docmost. Зверніться до sales@docmost.com.", "AI & MCP": "ШІ та MCP", "AI": "ШІ", "MCP": "MCP", diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 97ff9ce85..1b273e71c 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "升级您的方案", "Available with a paid license": "需付费许可才可用", "Upgrade your license tier.": "升级您的许可等级。", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI 在 Docmost 付费版中提供。请联系 sales@docmost.com。", "AI & MCP": "AI 与 MCP", "AI": "AI", "MCP": "MCP", diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx new file mode 100644 index 000000000..732800299 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx @@ -0,0 +1,74 @@ +import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; + +export default function AiChatReadOnly() { + const { t } = useTranslation(); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + + return ( + +
+ + {t("Read-only mode")} + {!hasAccess && ( + + {t("Enterprise")} + + )} + + + {t( + "AI Chat can search and read workspace content, but cannot create or edit pages.", + )} + +
+ + +
+ ); +} + +function AiChatReadOnlyToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.ai?.chatReadOnly, + ); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + const upgradeLabel = useUpgradeLabel(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ + aiChatReadOnly: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err: any) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + + + ); +} diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx new file mode 100644 index 000000000..497a337b4 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx @@ -0,0 +1,74 @@ +import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; + +export default function AiChatWorkspaceKnowledgeOnly() { + const { t } = useTranslation(); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + + return ( + +
+ + {t("Workspace knowledge only")} + {!hasAccess && ( + + {t("Enterprise")} + + )} + + + {t( + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.", + )} + +
+ + +
+ ); +} + +function AiChatWorkspaceKnowledgeOnlyToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.ai?.chatWorkspaceKnowledgeOnly, + ); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + const upgradeLabel = useUpgradeLabel(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ + aiChatWorkspaceKnowledgeOnly: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err: any) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + + + ); +} diff --git a/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx index 9583a2f03..423557c53 100644 --- a/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx +++ b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx @@ -6,7 +6,10 @@ import { IconFileText, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; -import ChatInput from "./chat-input"; +import { useAtomValue } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useRef } from "react"; +import ChatInput, { ChatInputHandle } from "./chat-input"; import type { ChatAttachment, PageMention } from "../types/ai-chat.types"; import classes from "../styles/ai-chat.module.css"; @@ -14,6 +17,7 @@ type Suggestion = { icon: React.ReactNode; text: string; prompt: string; + write?: boolean; }; const SUGGESTIONS: Suggestion[] = [ @@ -26,6 +30,7 @@ const SUGGESTIONS: Suggestion[] = [ icon: , text: "Create a new page", prompt: "Create a new page titled ", + write: true, }, { icon: , @@ -36,6 +41,7 @@ const SUGGESTIONS: Suggestion[] = [ icon: , text: "Update page content", prompt: "Update the page @", + write: true, }, ]; @@ -47,9 +53,13 @@ type Props = { export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) { const { t } = useTranslation(); + const workspace = useAtomValue(workspaceAtom); + const writesDisabled = workspace?.settings?.ai?.chatReadOnly === true; + + const inputRef = useRef(null); const handleSuggestionClick = (prompt: string) => { - onSend(prompt, [], []); + inputRef.current?.prefill(prompt); }; return ( @@ -62,6 +72,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {

{t("Get started")}

- {SUGGESTIONS.map((s) => ( + {SUGGESTIONS.filter((s) => !writesDisabled || !s.write).map((s) => ( + )} + + {!isAiMode && + orderedVisibleFilters.map((filterKey) => { + if (filterKey === "creator") { + return ( + + setOpenedFilter(opened ? "creator" : null) + } + > + + + ); + } + + if (filterKey === "labels") { + return ( + + setOpenedFilter(opened ? "labels" : null) + } + > + + + ); + } + + return null; + })} + + {!isAiMode && addableFilters.length > 0 && ( + + + + + + {addableFilters.map((filter) => ( + } + onClick={() => revealFilter(filter.key)} + > + {filter.label} + + ))} + + + )}
); } diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index 3ada7ab1f..4e7977650 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -1,7 +1,7 @@ import { Spotlight } from "@mantine/spotlight"; import { IconSearch, IconSparkles } from "@tabler/icons-react"; -import { Group, Button, VisuallyHidden } from "@mantine/core"; -import React, { useState, useMemo, useEffect } from "react"; +import { Group, Button, VisuallyHidden, Text } from "@mantine/core"; +import React, { useState, useMemo, useEffect, useCallback } from "react"; import { useDebouncedValue } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { notifications } from "@mantine/notifications"; @@ -31,6 +31,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { const [filters, setFilters] = useState<{ spaceId?: string | null; contentType?: string; + creatorId?: string | null; + labelIds?: string[]; + titleOnly?: boolean; }>({ contentType: "page", }); @@ -48,10 +51,25 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { params.spaceId = filters.spaceId; } + if (filters.creatorId) { + params.creatorId = filters.creatorId; + } + + if (filters.labelIds?.length) { + params.labelIds = filters.labelIds; + } + + if (filters.titleOnly) { + params.titleOnly = true; + } + return params; }, [debouncedSearchQuery, filters]); - const { data: searchResults, isLoading } = useUnifiedSearch( + const { + data: searchResults, + isFetching, + } = useUnifiedSearch( searchParams, !isAiMode // Disable regular search when in AI mode ); @@ -88,6 +106,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } }, [aiSearchError, t]); + const isFilterBrowse = + (filters.labelIds?.length ?? 0) > 0 || !!filters.creatorId; + // while the debounce is pending the empty list is not a settled "no results" + const isQuerySettled = query === debouncedSearchQuery; + // Determine result type for rendering const isAttachmentSearch = filters.contentType === "attachment" && hasAttachmentIndexing; @@ -110,9 +133,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } }; - const handleFiltersChange = (newFilters: any) => { + const handleFiltersChange = useCallback((newFilters: any) => { setFilters(newFilters); - }; + }, [setFilters]); const handleAskClick = () => { setIsAiMode(!isAiMode); @@ -182,7 +205,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { ? query.length > 0 && !isAiLoading && !aiSearchResult ? t("No answer available") : "" - : query.length > 0 && !isLoading + : (query.length > 0 || isFilterBrowse) && !isFetching ? resultItems.length === 0 ? t("No results found") : t("{{count}} results found", { count: resultItems.length }) @@ -209,15 +232,28 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { ) : ( <> - {query.length === 0 && resultItems.length === 0 && ( + {query.length === 0 && !isFilterBrowse && resultItems.length === 0 && ( {t("Start typing to search...")} )} - {query.length > 0 && !isLoading && resultItems.length === 0 && ( - {t("No results found...")} - )} + {(query.length > 0 || isFilterBrowse) && + !isFetching && + isQuerySettled && + resultItems.length === 0 && ( + {t("No results found...")} + )} {resultItems.length > 0 && <>{resultItems}} + + {(query.length > 0 || isFilterBrowse) && + isFetching && + resultItems.length === 0 && ( + + + {t("Searching...")} + + + )} )} diff --git a/apps/client/src/features/search/hooks/use-unified-search.ts b/apps/client/src/features/search/hooks/use-unified-search.ts index 5d294f4bd..9270477b9 100644 --- a/apps/client/src/features/search/hooks/use-unified-search.ts +++ b/apps/client/src/features/search/hooks/use-unified-search.ts @@ -39,6 +39,20 @@ export function useUnifiedSearch( return await searchPage(backendParams); } }, - enabled: !!params.query && enabled, + enabled: + (!!params.query || + (params.labelIds?.length ?? 0) > 0 || + !!params.creatorId) && + enabled, + // keep previous results only within the same search type; page results + // rendered as attachments (or vice versa) crash on missing fields + placeholderData: (previousData, previousQuery) => { + if (!params.query && !params.labelIds?.length && !params.creatorId) + return undefined; + if (previousQuery && previousQuery.queryKey[1] !== searchType) { + return undefined; + } + return previousData; + }, }); } diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 9962b9ca2..60ae5d985 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -36,6 +36,9 @@ export interface IPageSearchParams { query: string; spaceId?: string; shareId?: string; + creatorId?: string; + labelIds?: string[]; + titleOnly?: boolean; } export interface IAttachmentSearch { diff --git a/apps/client/src/styles/a11y-overrides.css b/apps/client/src/styles/a11y-overrides.css index 25c1eafe7..1fb4264d9 100644 --- a/apps/client/src/styles/a11y-overrides.css +++ b/apps/client/src/styles/a11y-overrides.css @@ -33,3 +33,31 @@ color: var(--mantine-color-dimmed); -webkit-text-fill-color: var(--mantine-color-dimmed); } + +/* Spotlight's selected action ships as primary-filled blue with white text, + * but our custom action children (gray badge, dimmed snippet, gray icons) + * keep their light-surface colors on it and drop below WCAG AA 4.5:1 + * (WCAG 1.4.3). Use a gray selection one step above the gray-0/dark-6 + * hover instead - it matches the app's selection styling (we use no blue + * fills) and keeps every child at its already-passing resting contrast. + */ +.mantine-Spotlight-action[data-selected] { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); + color: var(--mantine-color-text); + --action-description-color: var(--mantine-color-dimmed); + --action-description-opacity: 1; +} + +/* Result actions are tab stops (tabIndex restored via ref); give keyboard + * focus the same gray treatment as arrow-key selection so both navigation + * modes read identically (WCAG 2.4.7 focus visible). + */ +.mantine-Spotlight-action:focus-visible { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); +} diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index 710f9d10b..2db13c817 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -116,8 +116,8 @@ export class AttachmentService { }); } - // Only index PDFs and DOCX files - if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) { + // Only index PDF, DOCX and TXT files + if (['.pdf', '.docx', '.txt'].includes(attachment.fileExt.toLowerCase())) { await this.attachmentQueue.add( QueueJob.ATTACHMENT_INDEX_CONTENT, { diff --git a/apps/server/src/core/label/label.service.ts b/apps/server/src/core/label/label.service.ts index f0d63eba6..9ec0ab300 100644 --- a/apps/server/src/core/label/label.service.ts +++ b/apps/server/src/core/label/label.service.ts @@ -7,12 +7,15 @@ import { executeTx } from '@docmost/db/utils'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { normalizeLabelName } from './utils'; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { EventName } from "src/common/events/event.contants"; @Injectable() export class LabelService { constructor( private readonly labelRepo: LabelRepo, private readonly pagePermissionRepo: PagePermissionRepo, + private readonly eventEmitter: EventEmitter2, @InjectKysely() private readonly db: KyselyDB, ) {} @@ -34,6 +37,12 @@ export class LabelService { attached.push(label); } }); + + this.eventEmitter.emit(EventName.PAGE_UPDATED, { + pageIds: [pageId], + workspaceId: workspaceId, + }); + return attached; } @@ -64,6 +73,11 @@ export class LabelService { await this.labelRepo.deleteLabel(labelId, workspaceId, trx); } }); + + this.eventEmitter.emit(EventName.PAGE_UPDATED, { + pageIds: [pageId], + workspaceId: workspaceId, + }); } async getPageLabels(pageId: string, pagination: PaginationOptions) { diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 8be6d338d..89fb1b289 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsNotEmpty, IsNumber, @@ -8,9 +9,9 @@ import { } from 'class-validator'; export class SearchDTO { - @IsNotEmpty() + @IsOptional() @IsString() - query: string; + query?: string; @IsOptional() @IsUUID() @@ -24,6 +25,15 @@ export class SearchDTO { @IsUUID() creatorId?: string; + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + labelIds?: string[]; + + @IsOptional() + @IsBoolean() + titleOnly?: boolean; + @IsOptional() @IsNumber() limit?: number; diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 9883b2654..3db29b274 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -29,12 +29,36 @@ export class SearchService { workspaceId: string; }, ): Promise<{ items: SearchResponseDto[] }> { - const { query } = searchParams; + const query = searchParams.query?.trim() ?? ''; + const labelIds = [...new Set(searchParams.labelIds ?? [])]; + // selected filters (labels, creator) are browsable without a query + const browseByFilters = + query.length < 1 && + (labelIds.length > 0 || Boolean(searchParams.creatorId)); - if (query.length < 1) { + if (query.length < 1 && !browseByFilters) { return { items: [] }; } - const searchQuery = tsquery(query.trim() + '*'); + const searchQuery = tsquery(query + '*'); + const titleOnly = searchParams.titleOnly === true; + const titleQuery = query; + // escape LIKE wildcards; ranking keeps the raw query + const titleLikeQuery = query.replace(/[\\%_]/g, '\\$&'); + + const rankColumn = browseByFilters + ? sql`0`.as('rank') + : titleOnly + ? sql`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as( + 'rank', + ) + : sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( + 'rank', + ); + const highlightColumn = browseByFilters || titleOnly + ? sql`''`.as('highlight') + : sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( + 'highlight', + ); let queryResults = this.db .selectFrom('pages') @@ -47,23 +71,41 @@ export class SearchService { 'creatorId', 'createdAt', 'updatedAt', - sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( - 'rank', - ), - sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( - 'highlight', - ), + rankColumn, + highlightColumn, ]) - .where( - 'tsv', - '@@', - sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + .$if(!browseByFilters && !titleOnly, (qb) => + qb.where( + 'tsv', + '@@', + sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + ), + ) + .$if(!browseByFilters && titleOnly, (qb) => + qb.where((eb) => + eb( + sql`lower(f_unaccent(pages.title))`, + 'like', + sql`lower(f_unaccent(${`%${titleLikeQuery}%`}))`, + ), + ), ) .$if(Boolean(searchParams.creatorId), (qb) => qb.where('creatorId', '=', searchParams.creatorId), ) + .$if(labelIds?.length > 0, (qb) => + qb.where( + 'id', + 'in', + this.db + .selectFrom('pageLabels') + .select('pageId') + .where('labelId', 'in', labelIds), + ), + ) .where('deletedAt', 'is', null) - .orderBy('rank', 'desc') + .$if(browseByFilters, (qb) => qb.orderBy('updatedAt', 'desc')) + .$if(!browseByFilters, (qb) => qb.orderBy('rank', 'desc')) .limit(searchParams.limit || 25) .offset(searchParams.offset || 0); @@ -71,8 +113,7 @@ export class SearchService { queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); } - if (searchParams.spaceId) { - // search by spaceId + if (searchParams.spaceId && opts.userId) { queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); } else if (opts.userId && !searchParams.spaceId) { // only search spaces the user is a member of diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 03ee31555..e567e6caa 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -46,8 +46,9 @@ export class ShareService { throw new NotFoundException('Share not found'); } - const isRestricted = - await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId); + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + share.pageId, + ); if (isRestricted) { throw new NotFoundException('Share not found'); } @@ -110,6 +111,9 @@ export class ShareService { } async getSharedPage(dto: ShareInfoDto, workspaceId: string) { + //TODO: we should resolve the page from the share id + if (!dto.pageId) throw new NotFoundException('Shared page not found'); + const share = await this.getShareForPage(dto.pageId, workspaceId); if (!share) { @@ -126,8 +130,9 @@ export class ShareService { } // Block access to restricted pages - const isRestricted = - await this.pagePermissionRepo.hasRestrictedAncestor(page.id); + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); if (isRestricted) { throw new NotFoundException('Shared page not found'); } diff --git a/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts new file mode 100644 index 000000000..3cadad07b --- /dev/null +++ b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts @@ -0,0 +1,17 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX IF NOT EXISTS pages_title_trgm_idx ON pages USING gin (lower(f_unaccent(title)) gin_trgm_ops)`.execute( + db, + ); + + // separators normalized to spaces so space-typed queries match How_to_export.pdf + await sql`CREATE INDEX IF NOT EXISTS attachments_file_name_trgm_idx ON attachments USING gin (lower(f_unaccent(translate(file_name, '_.-', ' '))) gin_trgm_ops)`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX IF EXISTS attachments_file_name_trgm_idx`.execute(db); + await sql`DROP INDEX IF EXISTS pages_title_trgm_idx`.execute(db); +} diff --git a/apps/server/src/ee b/apps/server/src/ee index 62f745608..b51a46ef1 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 62f7456089d97ceedbcbad1ceabce09a4c7e1301 +Subproject commit b51a46ef104cb407a670462a36be79ef0da94f97 From 3a25336d3d6d4a7e9b5884fbdac1bf6c37ae4e2e Mon Sep 17 00:00:00 2001 From: Salihu <91833785+salihudickson@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:39:55 +0100 Subject: [PATCH 5/5] fix: display subpages correctly on first load (#2423) --- .../editor/components/subpages/subpages-view.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.tsx b/apps/client/src/features/editor/components/subpages/subpages-view.tsx index cfea74e10..4264a1021 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-view.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-view.tsx @@ -13,14 +13,17 @@ import { import { useTranslation } from "react-i18next"; import { sortPositionKeys } from "@/features/page/tree/utils/utils"; import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages"; +import { extractPageSlugId } from "@/lib"; export default function SubpagesView(props: NodeViewProps) { const { editor } = props; - const { spaceSlug, shareId } = useParams(); + const { spaceSlug, shareId, pageSlug } = useParams(); const { t } = useTranslation(); - - //@ts-ignore - const currentPageId = editor.storage.pageId; + + // @ts-ignore + const storagePageId = editor.storage.pageId; + const routePageId = extractPageSlugId(pageSlug); + const currentPageId = storagePageId ?? routePageId; // Get subpages from shared tree if we're in a shared context const sharedSubpages = useSharedPageSubpages(currentPageId);