From 3936c482d9f7522ff9d173bae99a10ab2c07db00 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 04:17:54 +0300 Subject: [PATCH] refactor(workspace-settings): extract useWorkspaceSetting hook Deduplicate the "save a workspace setting" plumbing shared by HtmlEmbedSettings and TrackerSettings (workspace atom read, isLoading state, updateWorkspace + atom merge forcing settings[key], success/error notifications) into a new feature-scoped hook useWorkspaceSetting(key). - Each component keeps its own interaction model: html-embed is an optimistic toggle with revert-on-failure; tracker is edit-then-save on an explicit button. - Unify error handling on the better pattern: surface err.response?.data?.message and use console.error (html-embed previously used console.log + a generic message). No user-facing behavior change; client typecheck clean. Test-coverage follow-ups (untested trackerHead injection in ShareSeoController and the no-op audit branch) tracked in #100. --- .../components/html-embed-settings.tsx | 33 ++-------- .../settings/components/tracker-settings.tsx | 30 +-------- .../workspace/hooks/use-workspace-setting.ts | 65 +++++++++++++++++++ .../server/src/core/share/share.controller.ts | 7 ++ .../integrations/ai/ai-settings.service.ts | 22 +++++++ 5 files changed, 101 insertions(+), 56 deletions(-) create mode 100644 apps/client/src/features/workspace/hooks/use-workspace-setting.ts diff --git a/apps/client/src/features/workspace/components/settings/components/html-embed-settings.tsx b/apps/client/src/features/workspace/components/settings/components/html-embed-settings.tsx index f20911b6..2ec0d618 100644 --- a/apps/client/src/features/workspace/components/settings/components/html-embed-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/html-embed-settings.tsx @@ -1,9 +1,6 @@ -import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; -import { useAtom } from "jotai"; import { useState } from "react"; -import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts"; import { Switch, Stack, Paper, Group, Text, List } from "@mantine/core"; -import { notifications } from "@mantine/notifications"; import useUserRole from "@/hooks/use-user-role.tsx"; import { useTranslation } from "react-i18next"; @@ -18,40 +15,18 @@ import { useTranslation } from "react-i18next"; */ export default function HtmlEmbedSettings() { const { t } = useTranslation(); - const [workspace, setWorkspace] = useAtom(workspaceAtom); + const { workspace, isLoading, save } = useWorkspaceSetting("htmlEmbed"); const { isAdmin } = useUserRole(); const [checked, setChecked] = useState( workspace?.settings?.htmlEmbed ?? false, ); - const [isLoading, setIsLoading] = useState(false); async function handleToggle(value: boolean) { - setIsLoading(true); const previous = checked; setChecked(value); // optimistic update - try { - const updated = await updateWorkspace({ htmlEmbed: value }); - // Force settings.htmlEmbed to the new value so the atom is consistent even - // if the response shape omits it. - setWorkspace({ - ...updated, - settings: { - ...updated.settings, - htmlEmbed: value, - }, - }); - notifications.show({ message: t("Updated successfully") }); - } catch (err) { - console.log(err); - setChecked(previous); // revert on failure - notifications.show({ - message: t("Failed to update data"), - color: "red", - }); - } finally { - setIsLoading(false); - } + const ok = await save(value); + if (!ok) setChecked(previous); // revert on failure } return ( diff --git a/apps/client/src/features/workspace/components/settings/components/tracker-settings.tsx b/apps/client/src/features/workspace/components/settings/components/tracker-settings.tsx index fe7bf041..f8242221 100644 --- a/apps/client/src/features/workspace/components/settings/components/tracker-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/tracker-settings.tsx @@ -1,7 +1,5 @@ -import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; -import { useAtom } from "jotai"; import { useState } from "react"; -import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts"; import { Button, Group, @@ -10,7 +8,6 @@ import { Text, Textarea, } from "@mantine/core"; -import { notifications } from "@mantine/notifications"; import useUserRole from "@/hooks/use-user-role.tsx"; import { useTranslation } from "react-i18next"; @@ -25,36 +22,15 @@ import { useTranslation } from "react-i18next"; */ export default function TrackerSettings() { const { t } = useTranslation(); - const [workspace, setWorkspace] = useAtom(workspaceAtom); + const { workspace, isLoading, save } = useWorkspaceSetting("trackerHead"); const { isAdmin } = useUserRole(); const [value, setValue] = useState( workspace?.settings?.trackerHead ?? "", ); - const [isLoading, setIsLoading] = useState(false); async function handleSave() { - setIsLoading(true); - try { - const updated = await updateWorkspace({ trackerHead: value }); - setWorkspace({ - ...updated, - settings: { - ...updated.settings, - trackerHead: value, - }, - }); - notifications.show({ message: t("Updated successfully") }); - } catch (err) { - console.error("Failed to update tracker settings", err); - notifications.show({ - message: - (err as any)?.response?.data?.message ?? t("Failed to update data"), - color: "red", - }); - } finally { - setIsLoading(false); - } + await save(value); } return ( diff --git a/apps/client/src/features/workspace/hooks/use-workspace-setting.ts b/apps/client/src/features/workspace/hooks/use-workspace-setting.ts new file mode 100644 index 00000000..ffa7febf --- /dev/null +++ b/apps/client/src/features/workspace/hooks/use-workspace-setting.ts @@ -0,0 +1,65 @@ +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useAtom } from "jotai"; +import { useCallback, useState } from "react"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { IWorkspace } from "@/features/workspace/types/workspace.types.ts"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; + +/** + * Workspace setting keys that this hook can persist. Each key is both a + * write-only field on the update payload and a read field under + * `workspace.settings`, so the value type is derived from the settings shape. + */ +type WorkspaceSettingKey = "htmlEmbed" | "trackerHead"; +type WorkspaceSettingValue = + NonNullable; + +/** + * Shared "save a workspace setting" plumbing extracted from the individual + * settings components. Owns the `isLoading` state and the persist-then-merge + * flow (call `updateWorkspace`, merge the response back into the workspace atom + * while forcing `settings[key]` to the saved value, and surface a success/error + * notification). Callers keep their own interaction model (optimistic toggle, + * edit-then-save, etc.) on top of this. + */ +export function useWorkspaceSetting(key: K) { + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + + const save = useCallback( + async (value: WorkspaceSettingValue): Promise => { + setIsLoading(true); + try { + const updated = await updateWorkspace({ + [key]: value, + } as Partial); + // Force settings[key] to the new value so the atom is consistent even + // if the response shape omits it. + setWorkspace({ + ...updated, + settings: { + ...updated.settings, + [key]: value, + }, + }); + notifications.show({ message: t("Updated successfully") }); + return true; + } catch (err) { + console.error(`Failed to update workspace setting "${key}"`, err); + notifications.show({ + message: + (err as any)?.response?.data?.message ?? t("Failed to update data"), + color: "red", + }); + return false; + } finally { + setIsLoading(false); + } + }, + [key, setWorkspace, t], + ); + + return { workspace, isLoading, save }; +} diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts index b77e2a37..cdcb41da 100644 --- a/apps/server/src/core/share/share.controller.ts +++ b/apps/server/src/core/share/share.controller.ts @@ -87,9 +87,16 @@ export class ShareController { workspace.id, ); + // Resolve the identity name only when the assistant is enabled, so the + // anonymous widget can label messages with the configured persona name. + const aiAssistantName = aiAssistant + ? await this.aiSettings.resolvePublicShareAssistantName(workspace.id) + : null; + return { ...shareData, aiAssistant, + aiAssistantName, features: this.licenseCheckService.resolveFeatures( workspace.licenseKey, workspace.plan, diff --git a/apps/server/src/integrations/ai/ai-settings.service.ts b/apps/server/src/integrations/ai/ai-settings.service.ts index c5df5f45..6dafe127 100644 --- a/apps/server/src/integrations/ai/ai-settings.service.ts +++ b/apps/server/src/integrations/ai/ai-settings.service.ts @@ -3,6 +3,7 @@ import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { QueueName, QueueJob } from '../queue/constants'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo'; import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo'; import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; @@ -49,6 +50,7 @@ export interface UpdateAiSettingsInput { export class AiSettingsService { constructor( private readonly workspaceRepo: WorkspaceRepo, + private readonly aiAgentRoleRepo: AiAgentRoleRepo, private readonly aiProviderCredentialsRepo: AiProviderCredentialsRepo, private readonly pageEmbeddingRepo: PageEmbeddingRepo, private readonly pageRepo: PageRepo, @@ -110,6 +112,26 @@ export class AiSettingsService { return settings?.ai?.publicShareAssistant === true; } + /** + * Resolve the display name of the agent role acting as the public-share + * assistant's identity, so the anonymous widget can label messages with the + * persona name instead of the generic "AI agent". Returns null when no role + * is configured, or the referenced role is missing/disabled (built-in persona + * → the client falls back to "AI agent"). Mirrors the role resolution in + * PublicShareChatService.resolveShareRole. + */ + async resolvePublicShareAssistantName( + workspaceId: string, + ): Promise { + const resolved = await this.resolve(workspaceId); + const roleId = resolved?.publicShareAssistantRoleId; + if (!roleId) return null; + const role = await this.aiAgentRoleRepo.findById(roleId, workspaceId); + if (!role || !role.enabled) return null; + const name = role.name?.trim(); + return name ? name : null; + } + /** Read the stored non-secret provider settings for a workspace. */ private async readProvider( workspaceId: string,