feat: support workspace lobehub (#13977)

feat: support workspace (full) — store→business-hook + workspace router
This commit is contained in:
Rdmclin2
2026-06-10 17:34:12 +08:00
committed by GitHub
parent c02e5720c2
commit e8e4b2e822
667 changed files with 17668 additions and 3786 deletions
+11 -3
View File
@@ -128,6 +128,7 @@ table agent_cron_jobs {
agent_id [name: 'agent_cron_jobs_agent_id_idx']
group_id [name: 'agent_cron_jobs_group_id_idx']
user_id [name: 'agent_cron_jobs_user_id_idx']
workspace_id [name: 'agent_cron_jobs_workspace_id_idx']
enabled [name: 'agent_cron_jobs_enabled_idx']
remaining_executions [name: 'agent_cron_jobs_remaining_executions_idx']
last_executed_at [name: 'agent_cron_jobs_last_executed_at_idx']
@@ -379,6 +380,7 @@ table agent_operations {
indexes {
user_id [name: 'agent_operations_user_id_idx']
workspace_id [name: 'agent_operations_workspace_id_idx']
agent_id [name: 'agent_operations_agent_id_idx']
topic_id [name: 'agent_operations_topic_id_idx']
thread_id [name: 'agent_operations_thread_id_idx']
@@ -461,7 +463,8 @@ table ai_models {
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
(id, provider_id, user_id) [pk]
(id, provider_id, user_id) [name: 'ai_models_id_provider_id_user_id_unique', unique]
(id, provider_id, user_id, workspace_id) [name: 'ai_models_id_provider_id_user_id_workspace_id_unique', unique]
user_id [name: 'ai_models_user_id_idx']
workspace_id [name: 'ai_models_workspace_id_idx']
}
@@ -488,7 +491,8 @@ table ai_providers {
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
(id, user_id) [pk]
(id, user_id) [name: 'ai_providers_id_user_id_unique', unique]
(id, user_id, workspace_id) [name: 'ai_providers_id_user_id_workspace_id_unique', unique]
user_id [name: 'ai_providers_user_id_idx']
workspace_id [name: 'ai_providers_workspace_id_idx']
}
@@ -778,6 +782,7 @@ table document_histories {
indexes {
document_id [name: 'document_histories_document_id_idx']
user_id [name: 'document_histories_user_id_idx']
workspace_id [name: 'document_histories_workspace_id_idx']
saved_at [name: 'document_histories_saved_at_idx']
workspace_id [name: 'document_histories_workspace_id_idx']
}
@@ -1027,6 +1032,7 @@ table llm_generation_tracing {
user_id [name: 'llm_generation_tracing_user_id_idx']
agent_id [name: 'llm_generation_tracing_agent_id_idx']
topic_id [name: 'llm_generation_tracing_topic_id_idx']
workspace_id [name: 'llm_generation_tracing_workspace_id_idx']
provider [name: 'llm_generation_tracing_provider_idx']
model [name: 'llm_generation_tracing_model_idx']
success [name: 'llm_generation_tracing_success_idx']
@@ -1793,6 +1799,7 @@ table file_chunks {
indexes {
(file_id, chunk_id) [pk]
user_id [name: 'file_chunks_user_id_idx']
workspace_id [name: 'file_chunks_workspace_id_idx']
file_id [name: 'file_chunks_file_id_idx']
chunk_id [name: 'file_chunks_chunk_id_idx']
workspace_id [name: 'file_chunks_workspace_id_idx']
@@ -1808,6 +1815,7 @@ table files_to_sessions {
indexes {
(file_id, session_id) [pk]
user_id [name: 'files_to_sessions_user_id_idx']
workspace_id [name: 'files_to_sessions_workspace_id_idx']
file_id [name: 'files_to_sessions_file_id_idx']
session_id [name: 'files_to_sessions_session_id_idx']
workspace_id [name: 'files_to_sessions_workspace_id_idx']
@@ -2169,8 +2177,8 @@ table topics {
provider [name: 'topics_provider_idx']
(user_id, completed_at) [name: 'topics_user_id_completed_at_idx']
sender_id [name: 'topics_sender_id_idx']
() [name: 'topics_extract_status_gin_idx']
workspace_id [name: 'topics_workspace_id_idx']
() [name: 'topics_extract_status_gin_idx']
}
}
@@ -1,4 +1,17 @@
import { router } from '@/libs/trpc/lambda';
import { TRPCError } from '@trpc/server';
import { authedProcedure, router } from '@/libs/trpc/lambda';
// Cloud overrides this at the same path with the real workspaceRouter backed by cloudDB.
export const workspaceRouter = router({});
// Only the procedures consumed by submodule (open-source) UI are declared here as
// typed no-op stubs so the contract type-checks; cloud supplies the real implementations.
export const workspaceRouter = router({
ensureMarketOrganization: authedProcedure.mutation(
async (): Promise<{ marketAccountId: number }> => {
throw new TRPCError({
code: 'NOT_IMPLEMENTED',
message: 'Workspace market organization is a cloud-only feature.',
});
},
),
});
+6
View File
@@ -124,6 +124,12 @@ const linkOptions = {
// Only include provider in JWT for image operations
// For other operations (like knowledge base embedding), let server use its own config
const headers = await createHeaderWithAuth(provider ? { provider } : undefined);
// Let business layer contribute extra headers (e.g. workspace context in Cloud).
// Community ships an empty stub at this slot.
const { getBusinessTrpcHeaders } = await import('@/business/client/trpc-headers');
Object.assign(headers as Record<string, string>, await getBusinessTrpcHeaders());
log('Headers: %O', headers);
return headers;
},
+7 -1
View File
@@ -66,7 +66,13 @@ export const toolsClient = createTRPCClient<ToolsRouter>({
// dynamic import to avoid circular dependency
const { createHeaderWithAuth } = await import('@/services/_auth');
return createHeaderWithAuth();
const headers = await createHeaderWithAuth();
// Let business layer contribute extra headers (e.g. workspace context in Cloud).
const { getBusinessTrpcHeaders } = await import('@/business/client/trpc-headers');
Object.assign(headers as Record<string, string>, await getBusinessTrpcHeaders());
return headers;
},
maxURLLength: 2083,
transformer: superjson,
@@ -0,0 +1,7 @@
export interface BusinessKnowledgeBaseImportActionProps {
knowledgeBaseId: string;
}
const BusinessKnowledgeBaseImportAction = (_props: BusinessKnowledgeBaseImportActionProps) => null;
export default BusinessKnowledgeBaseImportAction;
@@ -0,0 +1,3 @@
export default function WorkspaceBillingBilling() {
return null;
}
@@ -0,0 +1,3 @@
export default function WorkspaceBillingCredits() {
return null;
}
@@ -0,0 +1,3 @@
export default function WorkspaceBillingPlans() {
return null;
}
@@ -0,0 +1,3 @@
export default function WorkspaceBillingUsage() {
return null;
}
@@ -0,0 +1,3 @@
export default function WorkspaceGeneral() {
return null;
}
@@ -0,0 +1,3 @@
export default function WorkspaceMembers() {
return null;
}
@@ -0,0 +1,5 @@
import { type PropsWithChildren } from 'react';
export default function WorkspaceContextSlot({ children }: PropsWithChildren) {
return <>{children}</>;
}
@@ -0,0 +1,7 @@
interface UserPanelWorkspaceSectionProps {
onSwitch?: () => void;
}
export default function UserPanelWorkspaceSection(_props: UserPanelWorkspaceSectionProps) {
return null;
}
@@ -0,0 +1,13 @@
/**
* Active identity shown in the user button / panel header. In the open-source
* build there's only one identity (the signed-in user), so this stub returns
* `null` everywhere — callers fall back to user data. Cloud overrides this
* hook to return the active workspace's avatar / name when a team workspace
* is selected, so the header reflects the current context.
*/
export interface ActiveIdentity {
avatar?: string | null;
name?: string | null;
}
export const useActiveIdentity = (): ActiveIdentity | null => null;
@@ -0,0 +1,5 @@
import type { WorkspaceItem } from '@lobechat/database/schemas';
export type WorkspaceListItem = WorkspaceItem & { plan?: 'hobby' | 'pro'; role?: string };
export const useActiveWorkspace = (): WorkspaceListItem | null => null;
@@ -0,0 +1,3 @@
export const getActiveWorkspaceId = (): string | null => null;
export const useActiveWorkspaceId = (): string | null => null;
@@ -0,0 +1,3 @@
export const getActiveWorkspaceSlug = (): string | null => null;
export const useActiveWorkspaceSlug = (): string | null => null;
@@ -0,0 +1,3 @@
import type { ItemType } from 'antd/es/menu/interface';
export const useAgentGroupTransferMenuItem = (_groupId?: string): ItemType[] | null => null;
@@ -0,0 +1,3 @@
import { type ItemType } from 'antd/es/menu/interface';
export const useAgentTransferMenuItem = (_agentId?: string): ItemType[] | null => null;
@@ -0,0 +1,6 @@
export interface AuthorInfo {
avatar?: string | null;
fullName?: string | null;
}
export const useAuthorInfo = (_userId?: string): AuthorInfo | undefined => undefined;
@@ -0,0 +1,3 @@
import { type ItemType } from 'antd/es/menu/interface';
export const useBusinessAgentImportMenuItem = (_agentId?: string): ItemType | null => null;
@@ -0,0 +1,25 @@
'use client';
export interface CommunityWorkspaceMember {
accountId: number;
avatarUrl: string | null;
createdAt: string;
displayName: string | null;
namespace: string | null;
role: 'admin' | 'member';
userName: string | null;
}
export interface CommunityWorkspaceMembersState {
canSync: boolean;
isLoading: boolean;
members: CommunityWorkspaceMember[];
refresh: () => Promise<void>;
}
export const useCommunityWorkspaceMembers = (): CommunityWorkspaceMembersState => ({
canSync: false,
isLoading: false,
members: [],
refresh: async () => {},
});
@@ -0,0 +1,36 @@
'use client';
export interface CommunityWorkspaceMarketOrganizationProfile {
accountId: number;
avatarUrl: string | null;
bannerUrl: string | null;
description: string | null;
displayName: string | null;
namespace: string;
websiteUrl: string | null;
}
export interface CommunityWorkspaceProfileState {
avatarUrl?: string | null;
bannerUrl?: string | null;
canEdit: boolean;
description?: string | null;
displayName?: string | null;
isLoading: boolean;
isWorkspaceScope: boolean;
profile: CommunityWorkspaceMarketOrganizationProfile | null;
refresh: () => Promise<void>;
username?: string;
}
export const useCommunityWorkspaceProfile = (): CommunityWorkspaceProfileState => ({
avatarUrl: null,
bannerUrl: null,
canEdit: false,
description: null,
displayName: null,
isLoading: false,
isWorkspaceScope: false,
profile: null,
refresh: async () => {},
});
@@ -0,0 +1,3 @@
import { type ItemType } from 'antd/es/menu/interface';
export const useDocumentTransferMenuItem = (_documentId?: string): ItemType[] | null => null;
@@ -0,0 +1,7 @@
export interface FetchWorkspaceMembersOptions {
includeDeleted?: boolean;
}
export const useFetchWorkspaceMembers = (_options: FetchWorkspaceMembersOptions = {}) => ({
data: [],
});
@@ -0,0 +1,12 @@
import type { LucideIcon } from 'lucide-react';
export interface FileBatchTransferAction {
icon: LucideIcon;
key: string;
label: string;
onClick: () => void;
}
export const useFileBatchTransferActions = (
_selectCount: number,
): FileBatchTransferAction[] | null => null;
@@ -0,0 +1,6 @@
import { type ItemType } from 'antd/es/menu/interface';
export const useFileTransferMenuItem = (
_id?: string,
_entityType?: 'document' | 'file' | 'folder',
): ItemType[] | null => null;
@@ -0,0 +1 @@
export const useHasActiveWorkspace = (): boolean => false;
@@ -0,0 +1 @@
export const useHasWorkspace = (): boolean => false;
@@ -0,0 +1 @@
export const useIsWorkspaceLoading = (): boolean => false;
@@ -0,0 +1,3 @@
import { type ItemType } from 'antd/es/menu/interface';
export const useKnowledgeBaseTransferMenuItem = (_id?: string): ItemType[] | null => null;
@@ -0,0 +1 @@
export const useShowWorkspaceApiKey = (): boolean => true;
@@ -0,0 +1,11 @@
export interface SwitchWorkspaceActions {
switchToPersonal: () => Promise<void>;
switchWorkspace: (id: string) => Promise<void>;
}
const noop = async (): Promise<void> => {};
export const useSwitchWorkspace = (): SwitchWorkspaceActions => ({
switchToPersonal: noop,
switchWorkspace: noop,
});
@@ -0,0 +1,3 @@
import { type ItemType } from 'antd/es/menu/interface';
export const useTaskTransferMenuItem = (_taskId?: string): ItemType[] | null => null;
@@ -0,0 +1,3 @@
import type { FormGroupItemType } from '@lobehub/ui';
export const useTransferAgentsFormItem = (): FormGroupItemType['children'] | null => null;
@@ -0,0 +1,3 @@
import type { WorkspaceMemberItem } from '@lobechat/database/schemas';
export const useWorkspaceMembers = (): WorkspaceMemberItem[] => [];
@@ -0,0 +1,3 @@
import type { WorkspaceListItem } from './useActiveWorkspace';
export const useWorkspaces = (): WorkspaceListItem[] => [];
@@ -0,0 +1,29 @@
export interface UpdateCommunityWorkspaceProfileInput {
avatarUrl?: string | null;
bannerUrl?: string | null;
description?: string;
displayName?: string;
namespace?: string;
websiteUrl?: string;
}
export interface SetupCommunityWorkspaceProfileInput extends UpdateCommunityWorkspaceProfileInput {
displayName: string;
namespace: string;
}
export const setupCommunityWorkspaceProfile = async (
_input: SetupCommunityWorkspaceProfileInput,
): Promise<void> => {};
export const updateCommunityWorkspaceProfile = async (
_input: UpdateCommunityWorkspaceProfileInput,
): Promise<void> => {};
export const syncCommunityWorkspaceMembers = async (): Promise<void> => {};
export const checkCommunityWorkspaceNamespaceAvailable = async (
_namespace: string,
): Promise<boolean> => true;
export const isCommunityWorkspaceNamespaceTakenError = (_error: unknown): boolean => false;
+1
View File
@@ -0,0 +1 @@
export const getBusinessTrpcHeaders = async (): Promise<Record<string, string>> => ({});
@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { usePermission } from '@/hooks/usePermission';
import { useVisualMediaUploadAbility } from '@/hooks/useVisualMediaUploadAbility';
import { useFileStore } from '@/store/file';
@@ -20,9 +21,12 @@ export const useUploadFiles = (options: UseUploadFilesOptions = {}) => {
const { canUploadImage, canUploadVideo } = useVisualMediaUploadAbility(model, provider);
const uploadFiles = useFileStore((s) => s.uploadChatFiles);
const { allowed: canUpload } = usePermission('create_content');
const handleUploadFiles = useCallback(
async (files: File[]) => {
if (!canUpload) return;
// Filter out visual files if the model cannot receive them directly or via fallback.
const filteredFiles = files.filter((file) => {
if (file.type.startsWith('image')) return canUploadImage;
@@ -34,7 +38,7 @@ export const useUploadFiles = (options: UseUploadFilesOptions = {}) => {
uploadFiles(filteredFiles);
}
},
[canUploadImage, canUploadVideo, uploadFiles],
[canUpload, canUploadImage, canUploadVideo, uploadFiles],
);
return { canUploadImage, canUploadVideo, handleUploadFiles };
+3 -1
View File
@@ -3,16 +3,18 @@ import { Switch } from 'antd';
import { memo, useState } from 'react';
interface InstantSwitchProps {
disabled?: boolean;
enabled: boolean;
onChange: (enabled: boolean) => Promise<void>;
size?: SwitchProps['size'];
}
const InstantSwitch = memo<InstantSwitchProps>(({ enabled, onChange, size }) => {
const InstantSwitch = memo<InstantSwitchProps>(({ disabled, enabled, onChange, size }) => {
const [value, setValue] = useState(enabled);
const [loading, setLoading] = useState(false);
return (
<Switch
disabled={disabled}
loading={loading}
size={size}
value={value}
+20
View File
@@ -7,10 +7,12 @@ import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormAction } from '@/features/Conversation/Error/style';
import { usePermission } from '@/hooks/usePermission';
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
const BedrockForm = memo<{ description: string }>(({ description }) => {
const { t } = useTranslation('modelProvider');
const { allowed: canManageProvider } = usePermission('manage_provider_key');
const [showRegion, setShow] = useState(false);
const [showSessionToken, setShowSessionToken] = useState(false);
@@ -26,38 +28,50 @@ const BedrockForm = memo<{ description: string }>(({ description }) => {
>
<InputPassword
autoComplete={'new-password'}
disabled={!canManageProvider}
placeholder={'Aws Access Key Id'}
value={accessKeyId}
variant={'filled'}
onChange={(e) => {
if (!canManageProvider) return;
setConfig(ModelProvider.Bedrock, { keyVaults: { accessKeyId: e.target.value } });
}}
/>
<InputPassword
autoComplete={'new-password'}
disabled={!canManageProvider}
placeholder={'Aws Secret Access Key'}
value={secretAccessKey}
variant={'filled'}
onChange={(e) => {
if (!canManageProvider) return;
setConfig(ModelProvider.Bedrock, { keyVaults: { secretAccessKey: e.target.value } });
}}
/>
{showSessionToken ? (
<InputPassword
autoComplete={'new-password'}
disabled={!canManageProvider}
placeholder={'Aws Session Token'}
value={sessionToken}
variant={'filled'}
onChange={(e) => {
if (!canManageProvider) return;
setConfig(ModelProvider.Bedrock, { keyVaults: { sessionToken: e.target.value } });
}}
/>
) : (
<Button
block
disabled={!canManageProvider}
icon={ShieldPlus}
type={'text'}
onClick={() => {
if (!canManageProvider) return;
setShowSessionToken(true);
}}
>
@@ -66,6 +80,7 @@ const BedrockForm = memo<{ description: string }>(({ description }) => {
)}
{showRegion ? (
<Select
disabled={!canManageProvider}
placeholder={'https://api.openai.com/v1'}
style={{ width: '100%' }}
value={region}
@@ -74,15 +89,20 @@ const BedrockForm = memo<{ description: string }>(({ description }) => {
value: i,
}))}
onChange={(region) => {
if (!canManageProvider) return;
setConfig('bedrock', { keyVaults: { region } });
}}
/>
) : (
<Button
block
disabled={!canManageProvider}
icon={<Icon icon={Network} />}
type={'text'}
onClick={() => {
if (!canManageProvider) return;
setShow(true);
}}
>
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import KeyValueEditor from '@/components/KeyValueEditor';
import { FormAction } from '@/features/Conversation/Error/style';
import { usePermission } from '@/hooks/usePermission';
import { useAiInfraStore } from '@/store/aiInfra';
import { type ComfyUIKeyVault } from '@/types/user/settings';
@@ -43,6 +44,7 @@ const styles = createStaticStyles(({ css }) => ({
const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
const { t } = useTranslation('error');
const { t: s } = useTranslation('modelProvider');
const { allowed: canManageProvider } = usePermission('manage_provider_key');
// Use aiInfraStore for updating config (same as settings page)
const updateAiProviderConfig = useAiInfraStore((s) => s.updateAiProviderConfig);
@@ -103,6 +105,8 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
];
const handleValueChange = async (field: string, value: any) => {
if (!canManageProvider) return;
const newValues = {
...formValues,
[field]: value,
@@ -148,6 +152,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
<Flexbox gap={4}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.baseURL.title')}</div>
<FormInput
disabled={!canManageProvider}
placeholder={s('comfyui.baseURL.placeholder')}
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
value={formValues.baseURL}
@@ -156,6 +161,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
</Flexbox>
) : (
<Button
disabled={!canManageProvider}
icon={<Icon icon={Network} />}
type={'text'}
onClick={() => setShowBaseURL(true)}
@@ -169,6 +175,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.authType.title')}</div>
<Select
allowClear={false}
disabled={!canManageProvider}
options={authTypeOptions}
placeholder={s('comfyui.authType.placeholder')}
value={formValues.authType}
@@ -183,6 +190,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.username.title')}</div>
<FormInput
autoComplete="username"
disabled={!canManageProvider}
placeholder={s('comfyui.username.placeholder')}
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
value={formValues.username}
@@ -193,6 +201,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.password.title')}</div>
<FormPassword
autoComplete="new-password"
disabled={!canManageProvider}
placeholder={s('comfyui.password.placeholder')}
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
value={formValues.password}
@@ -208,6 +217,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.apiKey.title')}</div>
<FormPassword
autoComplete="new-password"
disabled={!canManageProvider}
placeholder={s('comfyui.apiKey.placeholder')}
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
value={formValues.apiKey}
@@ -228,6 +238,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
<KeyValueEditor
addButtonText={s('comfyui.customHeaders.addButton')}
deleteTooltip={s('comfyui.customHeaders.deleteTooltip')}
disabled={!canManageProvider}
duplicateKeyErrorText={s('comfyui.customHeaders.duplicateKeyError')}
keyPlaceholder={s('comfyui.customHeaders.keyPlaceholder')}
value={formValues.customHeaders}
@@ -27,7 +27,7 @@ const ProviderApiKeyForm = memo<ProviderApiKeyFormProps>(
const { t: errorT } = useTranslation('error');
const [showProxy, setShow] = useState(false);
const { apiKey, baseURL, setConfig } = useApiKey(provider);
const { apiKey, baseURL, canManageProvider, setConfig } = useApiKey(provider);
const { showOpenAIProxyUrl } = useServerConfigStore(featureFlagsSelectors);
const providerName = useProviderName(provider);
const { loading } = use(LoadingContext);
@@ -40,6 +40,7 @@ const ProviderApiKeyForm = memo<ProviderApiKeyFormProps>(
>
<FormPassword
autoComplete={'new-password'}
disabled={!canManageProvider}
placeholder={apiKeyPlaceholder || 'sk-***********************'}
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
value={apiKey}
@@ -52,6 +53,7 @@ const ProviderApiKeyForm = memo<ProviderApiKeyFormProps>(
showOpenAIProxyUrl &&
(showProxy ? (
<FormInput
disabled={!canManageProvider}
placeholder={'https://api.openai.com/v1'}
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
value={baseURL}
@@ -61,6 +63,7 @@ const ProviderApiKeyForm = memo<ProviderApiKeyFormProps>(
/>
) : (
<Button
disabled={!canManageProvider}
icon={<Icon icon={Network} />}
type={'text'}
onClick={() => {
@@ -1,19 +1,24 @@
import isEqual from 'fast-deep-equal';
import { useContext } from 'react';
import { usePermission } from '@/hooks/usePermission';
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
import { LoadingContext } from './LoadingContext';
export const useApiKey = (provider: string) => {
const { setLoading } = useContext(LoadingContext);
const { allowed: canManageProvider } = usePermission('manage_provider_key');
const updateAiProviderConfig = useAiInfraStore((s) => s.updateAiProviderConfig);
const data = useAiInfraStore(aiProviderSelectors.providerConfigById(provider), isEqual);
return {
apiKey: data?.keyVaults.apiKey,
baseURL: data?.keyVaults?.baseURL,
canManageProvider,
setConfig: async (id: string, params: Record<string, string>) => {
if (!canManageProvider) return;
const next = { ...data?.keyVaults, ...params };
if (isEqual(data?.keyVaults, next)) return;
+16
View File
@@ -41,6 +41,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
export interface KeyValueEditorProps {
addButtonText?: string;
deleteTooltip?: string;
disabled?: boolean;
duplicateKeyErrorText?: string;
keyPlaceholder?: string;
onChange?: (value: Record<string, string>) => void;
@@ -58,6 +59,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
addButtonText,
duplicateKeyErrorText,
deleteTooltip,
disabled,
style,
}) => {
const { t } = useTranslation('components');
@@ -73,6 +75,8 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
}, [value]);
const triggerChange = (newItems: KeyValueItem[]) => {
if (disabled) return;
const keysCount: Record<string, number> = {};
newItems.forEach((item) => {
const trimmedKey = item.key.trim();
@@ -89,21 +93,29 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
};
const handleAdd = () => {
if (disabled) return;
const newItems = [...items, { id: uuidv4(), key: '', value: '' }];
triggerChange(newItems);
};
const handleRemove = (id: string) => {
if (disabled) return;
const newItems = items.filter((item) => item.id !== id);
triggerChange(newItems);
};
const handleKeyChange = (id: string, newKey: string) => {
if (disabled) return;
const newItems = items.map((item) => (item.id === id ? { ...item, key: newKey } : item));
triggerChange(newItems);
};
const handleValueChange = (id: string, newValue: string) => {
if (disabled) return;
const newItems = items.map((item) => (item.id === id ? { ...item, value: newValue } : item));
triggerChange(newItems);
};
@@ -147,6 +159,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
<Flexbox flex={1} style={{ position: 'relative' }}>
<FormInput
className={styles.input}
disabled={disabled}
placeholder={keyPlaceholder || t('KeyValueEditor.keyPlaceholder')}
status={isDuplicate ? 'error' : undefined}
value={item.key}
@@ -169,6 +182,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
<Flexbox flex={2}>
<FormInput
className={styles.input}
disabled={disabled}
placeholder={valuePlaceholder || t('KeyValueEditor.valuePlaceholder')}
value={item.value}
variant={'filled'}
@@ -176,6 +190,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
/>
</Flexbox>
<ActionIcon
disabled={disabled}
icon={LucideTrash}
size={'small'}
style={{ marginTop: 4 }}
@@ -187,6 +202,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
})}
<Button
block
disabled={disabled}
icon={<Icon icon={LucidePlus} />}
size={'small'}
style={{ marginTop: items.length > 0 ? 16 : 8 }}
@@ -4,6 +4,7 @@ import { memo } from 'react';
import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone';
import { type ActionKeys } from '@/features/ChatInput';
import { ChatInput, ChatList } from '@/features/Conversation';
import { usePermission } from '@/hooks/usePermission';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
@@ -25,13 +26,18 @@ const AgentBuilderConversation = memo<AgentBuilderConversationProps>(({ agentId
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
const { handleUploadFiles } = useUploadFiles({ model, provider });
const { allowed: canCreate } = usePermission('create_content');
return (
<DragUploadZone style={{ flex: 1, height: '100%' }} onUploadFiles={handleUploadFiles}>
<DragUploadZone
disabled={!canCreate}
style={{ flex: 1, height: '100%' }}
onUploadFiles={handleUploadFiles}
>
<Flexbox flex={1} height={'100%'}>
<TopicSelector agentId={agentId} />
<TopicSelector agentId={agentId} disabled={!canCreate} />
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
<ChatList welcome={<AgentBuilderWelcome />} />
<ChatList welcome={<AgentBuilderWelcome disabled={!canCreate} />} />
</Flexbox>
<ChatInput leftActions={actions} rightActions={rightActions} showControlBar={false} />
</Flexbox>
@@ -12,10 +12,11 @@ import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
interface AgentBuilderWelcomeProps {
disabled?: boolean;
mode?: SuggestMode;
}
const AgentBuilderWelcome = memo<AgentBuilderWelcomeProps>(({ mode = 'agent' }) => {
const AgentBuilderWelcome = memo<AgentBuilderWelcomeProps>(({ disabled, mode = 'agent' }) => {
const { t } = useTranslation('chat');
const agentId = useConversationStore(conversationSelectors.agentId);
const agent = useAgentStore(agentByIdSelectors.getAgentConfigById(agentId));
@@ -37,7 +38,7 @@ const AgentBuilderWelcome = memo<AgentBuilderWelcomeProps>(({ mode = 'agent' })
<Markdown fontSize={14} variant={'chat'}>
{t('agentBuilder.welcome')}
</Markdown>
<SuggestQuestions count={3} mode={mode} />
<SuggestQuestions count={3} disabled={disabled} mode={mode} />
</Flexbox>
</>
);
@@ -0,0 +1,84 @@
/**
* @vitest-environment happy-dom
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TopicSelector from './TopicSelector';
const switchTopic = vi.fn();
const useFetchTopics = vi.fn();
vi.mock('@lobehub/ui', () => ({
ActionIcon: ({ disabled, onClick, title }: any) => (
<button disabled={disabled} type="button" onClick={onClick}>
{title}
</button>
),
DropdownMenu: ({ children }: any) => <div>{children}</div>,
Flexbox: ({ children }: any) => <div>{children}</div>,
}));
vi.mock('antd-style', () => ({
createStaticStyles: () => ({
time: 'time',
title: 'title',
}),
}));
vi.mock('dayjs', () => {
const dayjs = () => ({
diff: () => 0,
format: () => '2026-05-24',
fromNow: () => 'now',
});
return { default: dayjs };
});
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('@/const/layoutTokens', () => ({
DESKTOP_HEADER_ICON_SMALL_SIZE: 24,
}));
vi.mock('@/features/NavHeader', () => ({
default: ({ left, right }: any) => (
<div>
{left}
{right}
</div>
),
}));
vi.mock('@/store/chat', () => ({
useChatStore: (selector: any) =>
selector({
activeTopicId: 'topic-1',
switchTopic,
topics: [{ id: 'topic-1', title: 'First topic', updatedAt: new Date() }],
useFetchTopics,
}),
}));
vi.mock('@/store/chat/slices/topic/selectors', () => ({
topicSelectors: {
getTopicsByAgentId: () => (s: any) => s.topics,
},
}));
describe('AgentBuilder TopicSelector', () => {
beforeEach(() => {
switchTopic.mockReset();
useFetchTopics.mockReset();
});
it('does not create a new topic when disabled', () => {
render(<TopicSelector disabled agentId="agent-builder" />);
fireEvent.click(screen.getByRole('button', { name: 'actions.addNewTopic' }));
expect(switchTopic).not.toHaveBeenCalled();
});
});
+11 -4
View File
@@ -29,9 +29,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
interface TopicSelectorProps {
agentId: string;
disabled?: boolean;
}
const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
const TopicSelector = memo<TopicSelectorProps>(({ agentId, disabled }) => {
const { t } = useTranslation('topic');
// Fetch topics for the agent builder
@@ -70,6 +71,7 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
</Flexbox>
),
onCheckedChange: (checked) => {
if (disabled) return;
if (checked) {
switchTopic(topic.id);
}
@@ -90,18 +92,23 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
right={
<>
<ActionIcon
disabled={disabled}
icon={PlusIcon}
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
title={t('actions.addNewTopic')}
onClick={() => switchTopic()}
onClick={() => {
if (disabled) return;
switchTopic();
}}
/>
<DropdownMenu
items={items}
placement="bottomRight"
popupProps={{ style: { maxHeight: 400, minWidth: 280, overflowY: 'auto' } }}
triggerProps={{ disabled: isEmpty }}
triggerProps={{ disabled: disabled || isEmpty }}
>
<ActionIcon disabled={isEmpty} icon={Clock3Icon} />
<ActionIcon disabled={disabled || isEmpty} icon={Clock3Icon} />
</DropdownMenu>
</>
}
+4 -3
View File
@@ -5,10 +5,11 @@ import { cssVar } from 'antd-style';
import { BotMessageSquareIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import useSWR from 'swr';
import { SESSION_CHAT_TOPIC_URL } from '@/const/url';
import WorkspaceLink from '@/features/Workspace/WorkspaceLink';
import { topicService } from '@/services/topic';
import SectionHeader from './SectionHeader';
@@ -35,7 +36,7 @@ const AgentRecentTopics = memo(() => {
/>
<Flexbox horizontal gap={12} style={{ overflowX: 'auto', paddingBottom: 4 }}>
{topics.map((topic) => (
<Link
<WorkspaceLink
key={topic.id}
style={{ color: 'inherit', flexShrink: 0, textDecoration: 'none' }}
to={SESSION_CHAT_TOPIC_URL(aid!, topic.id)}
@@ -60,7 +61,7 @@ const AgentRecentTopics = memo(() => {
</Text>
</Flexbox>
</Block>
</Link>
</WorkspaceLink>
))}
</Flexbox>
</Flexbox>
+4 -3
View File
@@ -4,7 +4,8 @@ import { Flexbox, Icon, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { type LucideIcon } from 'lucide-react';
import { memo } from 'react';
import { Link } from 'react-router-dom';
import WorkspaceLink from '@/features/Workspace/WorkspaceLink';
interface SectionHeaderProps {
actionLabel?: string;
@@ -21,11 +22,11 @@ const SectionHeader = memo<SectionHeaderProps>(({ icon, title, actionLabel, acti
<Text color={cssVar.colorTextSecondary}>{title}</Text>
</Flexbox>
{actionLabel && actionUrl && (
<Link to={actionUrl} style={{ color: 'inherit', textDecoration: 'none' }}>
<WorkspaceLink style={{ color: 'inherit', textDecoration: 'none' }} to={actionUrl}>
<Text fontSize={12} type={'secondary'}>
{actionLabel}
</Text>
</Link>
</WorkspaceLink>
)}
</Flexbox>
);
@@ -8,10 +8,10 @@ import { createStaticStyles } from 'antd-style';
import { BookOpen, FileText, Settings } from 'lucide-react';
import { memo, type PropsWithChildren, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import ModelSelect from '@/features/ModelSelect';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { agentService } from '@/services/agent';
import { useAgentGroupStore } from '@/store/agentGroup';
@@ -71,7 +71,7 @@ interface AgentProfilePopupProps extends PropsWithChildren {
const AgentProfilePopup = memo<AgentProfilePopupProps>(
({ agent, agentId, groupId, children, trigger = 'click' }) => {
const { t } = useTranslation('chat');
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -11,6 +11,7 @@ import { globalGeneralSelectors } from '@/store/global/selectors';
export interface AutoGenerateAvatarProps {
background?: string;
canAutoGenerate?: boolean;
disabled?: boolean;
loading?: boolean;
onChange?: (value: string) => void;
onGenerate?: () => void;
@@ -18,7 +19,7 @@ export interface AutoGenerateAvatarProps {
}
const AutoGenerateAvatar = memo<AutoGenerateAvatarProps>(
({ loading, background, value, onChange, onGenerate, canAutoGenerate }) => {
({ loading, background, value, onChange, onGenerate, canAutoGenerate, disabled }) => {
const { t } = useTranslation('common');
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
@@ -45,11 +46,16 @@ const AutoGenerateAvatar = memo<AutoGenerateAvatarProps>(
value={value}
style={{
background: cssVar.colorFillTertiary,
opacity: disabled ? 0.5 : 1,
}}
onChange={(next) => {
if (disabled) return;
onChange?.(next);
}}
onChange={onChange}
/>
<ActionIcon
disabled={!canAutoGenerate}
disabled={disabled || !canAutoGenerate}
icon={Wand2}
loading={loading}
size="small"
@@ -6,11 +6,19 @@ import { useTranslation } from 'react-i18next';
import { DEFAULT_BACKGROUND_COLOR } from '@/const/meta';
interface BackgroundSwatchesProps extends Omit<ColorSwatchesProps, 'colors'> {
disabled?: boolean;
onValuesChange?: ColorSwatchesProps['onChange'];
}
const BackgroundSwatches = memo<BackgroundSwatchesProps>(
({ defaultValue = DEFAULT_BACKGROUND_COLOR, value, onChange, onValuesChange, ...rest }) => {
({
defaultValue = DEFAULT_BACKGROUND_COLOR,
value,
onChange,
onValuesChange,
disabled,
...rest
}) => {
const { t } = useTranslation('color');
const colors = useMemo(
@@ -77,7 +85,15 @@ const BackgroundSwatches = memo<BackgroundSwatchesProps>(
colors={colors}
defaultValue={defaultValue}
value={value}
style={{
cursor: disabled ? 'not-allowed' : undefined,
opacity: disabled ? 0.5 : undefined,
pointerEvents: disabled ? 'none' : undefined,
...rest.style,
}}
onChange={(v) => {
if (disabled) return;
onChange?.(v);
onValuesChange?.(v);
}}
+32 -13
View File
@@ -22,12 +22,15 @@ const AgentMeta = memo(() => {
const { t } = useTranslation('setting');
const [form] = Form.useForm();
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
const [hasSystemRole, updateMeta, autocompleteMeta, autocompleteAllMeta] = useStore((s) => [
!!s.config.systemRole,
s.setAgentMeta,
s.autocompleteMeta,
s.autocompleteAllMeta,
]);
const [hasSystemRole, disabled, updateMeta, autocompleteMeta, autocompleteAllMeta] = useStore(
(s) => [
!!s.config.systemRole,
s.disabled,
s.setAgentMeta,
s.autocompleteMeta,
s.autocompleteAllMeta,
],
);
const [isInbox, loadingState] = useStore((s) => [s.id === INBOX_SESSION_ID, s.loadingState]);
const meta = useStore(selectors.currentMetaConfig, isEqual);
const [background, setBackground] = useState(meta.backgroundColor);
@@ -66,10 +69,13 @@ const AgentMeta = memo(() => {
return {
children: (
<AutoGenerate
canAutoGenerate={hasSystemRole}
canAutoGenerate={hasSystemRole && !disabled}
disabled={disabled}
loading={loadingState?.[item.key]}
placeholder={item.placeholder}
onGenerate={() => {
if (disabled) return;
autocompleteMeta(item.key as keyof typeof meta);
}}
/>
@@ -85,9 +91,14 @@ const AgentMeta = memo(() => {
children: (
<AutoGenerateAvatar
background={background}
canAutoGenerate={hasSystemRole}
canAutoGenerate={hasSystemRole && !disabled}
disabled={disabled}
loading={loadingState?.['avatar']}
onGenerate={() => autocompleteMeta('avatar')}
onGenerate={() => {
if (disabled) return;
autocompleteMeta('avatar');
}}
/>
),
label: t('settingAgent.avatar.title'),
@@ -96,7 +107,9 @@ const AgentMeta = memo(() => {
name: 'avatar',
},
{
children: <BackgroundSwatches onValuesChange={(c) => setBackground(c)} />,
children: (
<BackgroundSwatches disabled={disabled} onValuesChange={(c) => setBackground(c)} />
),
label: t('settingAgent.backgroundColor.title'),
minWidth: undefined,
name: 'backgroundColor',
@@ -112,7 +125,7 @@ const AgentMeta = memo(() => {
}
>
<Button
disabled={!hasSystemRole}
disabled={disabled || !hasSystemRole}
icon={Wand2}
iconPlacement={'end'}
loading={Object.values(loadingState as any).some((i) => !!i)}
@@ -122,6 +135,8 @@ const AgentMeta = memo(() => {
}}
onClick={(e: any) => {
e.stopPropagation();
if (disabled) return;
autocompleteAllMeta(true);
}}
>
@@ -134,14 +149,18 @@ const AgentMeta = memo(() => {
return (
<Form
disabled={!isAgentEditable}
disabled={disabled || !isAgentEditable}
footer={<Form.SubmitFooter />}
form={form}
initialValues={meta}
items={[metaData]}
itemsType={'group'}
variant={'borderless'}
onFinish={updateMeta}
onFinish={(values) => {
if (disabled) return;
updateMeta(values);
}}
{...FORM_STYLE}
/>
);
@@ -28,22 +28,26 @@ const OpeningMessage = memo(() => {
const { t } = useTranslation('setting');
const openingMessage = useStore(selectors.openingMessage);
const updateConfig = useStore((s) => s.setAgentConfig);
const [disabled, updateConfig] = useStore((s) => [s.disabled, s.setAgentConfig]);
const setOpeningMessage = useCallback(
(message: string) => {
if (disabled) return;
updateConfig({ openingMessage: message });
},
[updateConfig],
[disabled, updateConfig],
);
const [editing, setEditing] = useState(false);
const handleEdit = useCallback(() => {
setEditing(true);
}, []);
if (disabled) return;
const editIconButton = !editing && openingMessage && (
<Button size={'small'} onClick={handleEdit}>
setEditing(true);
}, [disabled]);
const editIconButton = !editing && openingMessage && !disabled && (
<Button disabled={disabled} size={'small'} onClick={handleEdit}>
<PencilLine size={16} />
</Button>
);
@@ -52,11 +56,11 @@ const OpeningMessage = memo(() => {
<div className={styles.wrapper}>
<Flexbox direction={'horizontal'}>
<EditableMessage
showEditWhenEmpty
editButtonSize={'small'}
editing={editing}
height={'auto'}
placeholder={t('settingOpening.openingMessage.placeholder')}
showEditWhenEmpty={!disabled}
value={openingMessage ?? ''}
variant={'borderless'}
classNames={{
@@ -67,7 +71,11 @@ const OpeningMessage = memo(() => {
confirm: t('ok', { ns: 'common' }),
}}
onChange={setOpeningMessage}
onEditingChange={setEditing}
onEditingChange={(next) => {
if (disabled) return;
setEditing(next);
}}
/>
{editIconButton}
</Flexbox>
@@ -43,11 +43,15 @@ const OpeningQuestions = memo(() => {
const [questionInput, setQuestionInput] = useState('');
const openingQuestions = useStore(selectors.openingQuestions);
const updateConfig = useStore((s) => s.setAgentConfig);
const [disabled, updateConfig] = useStore((s) => [s.disabled, s.setAgentConfig]);
// Optimistic update to avoid jitter
const [questions, setQuestions] = useMergeState(openingQuestions, {
onChange: (questions: string[]) => updateConfig({ openingQuestions: questions }),
onChange: (questions: string[]) => {
if (disabled) return;
updateConfig({ openingQuestions: questions });
},
value: openingQuestions,
});
@@ -59,28 +63,33 @@ const OpeningQuestions = memo(() => {
}, [questions]);
const addQuestion = useCallback(() => {
if (disabled) return;
if (!questionInput.trim()) return;
setQuestions([...openingQuestions, questionInput.trim()]);
setQuestionInput('');
}, [openingQuestions, questionInput, setQuestions]);
}, [disabled, openingQuestions, questionInput, setQuestions]);
const removeQuestion = useCallback(
(content: string) => {
if (disabled) return;
const newQuestions = [...openingQuestions];
const index = newQuestions.indexOf(content);
newQuestions.splice(index, 1);
setQuestions(newQuestions);
},
[openingQuestions, setQuestions],
[disabled, openingQuestions, setQuestions],
);
// Handle logic after drag-and-drop sorting
const handleSortEnd = useCallback(
(items: QuestionItem[]) => {
if (disabled) return;
setQuestions(items.map((item) => item.content));
},
[setQuestions],
[disabled, setQuestions],
);
const isRepeat = openingQuestions.includes(questionInput.trim());
@@ -90,6 +99,7 @@ const OpeningQuestions = memo(() => {
<Flexbox gap={4} width={'100%'}>
<Space.Compact style={{ width: '100%' }}>
<Input
disabled={disabled}
placeholder={t('settingOpening.openingQuestions.placeholder')}
style={{ flex: 1 }}
value={questionInput}
@@ -98,7 +108,7 @@ const OpeningQuestions = memo(() => {
/>
<Button
// don't allow repeat
disabled={openingQuestions.includes(questionInput.trim())}
disabled={disabled || openingQuestions.includes(questionInput.trim())}
icon={PlusIcon}
onClick={addQuestion}
/>
@@ -119,9 +129,10 @@ const OpeningQuestions = memo(() => {
id={item.id}
variant={'filled'}
>
<SortableList.DragHandle />
{!disabled && <SortableList.DragHandle />}
<div className={styles.questionItemContent}>{item.content}</div>
<ActionIcon
disabled={disabled}
icon={Trash}
size={'small'}
onClick={() => removeQuestion(item.content)}
@@ -9,8 +9,11 @@ import DevModal from '@/features/PluginDevModal';
import { useAgentStore } from '@/store/agent';
import { useToolStore } from '@/store/tool';
import { useStore } from '../store';
const AddPluginButton = ({ ref, ...props }: ButtonProps & { ref?: Ref<HTMLButtonElement> }) => {
const { t } = useTranslation('setting');
const disabled = useStore((s) => s.disabled);
const [showModal, setModal] = useState(false);
const toggleAgentPlugin = useAgentStore((s) => s.toggleAgentPlugin);
const [installCustomPlugin, updateNewDevPlugin] = useToolStore((s) => [
@@ -25,19 +28,29 @@ const AddPluginButton = ({ ref, ...props }: ButtonProps & { ref?: Ref<HTMLButton
}}
>
<DevModal
open={showModal}
onOpenChange={setModal}
open={!disabled && showModal}
onValueChange={updateNewDevPlugin}
onOpenChange={(next) => {
if (disabled) return;
setModal(next);
}}
onSave={async (devPlugin) => {
if (disabled) return;
await installCustomPlugin(devPlugin);
toggleAgentPlugin(devPlugin.identifier);
}}
/>
<Button
{...props}
disabled={disabled}
icon={Grid2x2Plus}
ref={ref}
size={'small'}
onClick={() => {
if (disabled) return;
setModal(true);
}}
>
@@ -5,14 +5,21 @@ import { memo } from 'react';
import { useStore } from '../store';
const MarketList = memo<{ id: string }>(({ id }) => {
const [toggleAgentPlugin, hasPlugin] = useStore((s) => [s.toggleAgentPlugin, !!s.config.plugins]);
const [toggleAgentPlugin, hasPlugin, disabled] = useStore((s) => [
s.toggleAgentPlugin,
!!s.config.plugins,
s.disabled,
]);
const plugins = useStore((s) => s.config.plugins || []);
return (
<Flexbox horizontal align={'center'} gap={8}>
<Switch
checked={!hasPlugin ? false : plugins.includes(id)}
disabled={disabled}
onChange={() => {
if (disabled) return;
toggleAgentPlugin(id);
}}
/>
@@ -9,15 +9,17 @@ import { useStore } from '../../store';
const PluginSwitch = memo<{ identifier: string }>(({ identifier }) => {
const pluginManifestLoading = useToolStore((s) => s.pluginInstallLoading, isEqual);
const [userEnabledPlugins, hasPlugin, toggleAgentPlugin] = useStore((s) => [
const [userEnabledPlugins, hasPlugin, disabled, toggleAgentPlugin] = useStore((s) => [
s.config.plugins || [],
!!s.config.plugins,
s.disabled,
s.toggleAgentPlugin,
]);
return (
<Flexbox horizontal align={'center'} gap={8}>
<Switch
disabled={disabled}
loading={pluginManifestLoading[identifier]}
checked={
// If loading, it means it's activated
@@ -26,6 +28,8 @@ const PluginSwitch = memo<{ identifier: string }>(({ identifier }) => {
: userEnabledPlugins.includes(identifier)
}
onChange={() => {
if (disabled) return;
toggleAgentPlugin(identifier);
}}
/>
@@ -7,12 +7,13 @@ import isEqual from 'fast-deep-equal';
import { BlocksIcon, LucideTrash2, Store } from 'lucide-react';
import { memo, useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import PluginTag from '@/components/Plugins/PluginTag';
import { FORM_STYLE } from '@/const/layoutTokens';
import { createSkillStoreModal } from '@/features/SkillStore';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import WorkspaceLink from '@/features/Workspace/WorkspaceLink';
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { pluginHelpers, useToolStore } from '@/store/tool';
@@ -27,14 +28,15 @@ import PluginAction from './PluginAction';
const AgentPlugin = memo(() => {
const { t } = useTranslation('setting');
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const handleOpenStore = useCallback(() => {
createSkillStoreModal();
}, []);
const [userEnabledPlugins, toggleAgentPlugin] = useStore((s) => [
const [userEnabledPlugins, disabled, toggleAgentPlugin] = useStore((s) => [
s.config.plugins || [],
s.disabled,
s.toggleAgentPlugin,
]);
@@ -79,7 +81,10 @@ const AgentPlugin = memo(() => {
children: (
<Switch
checked={true}
disabled={disabled}
onChange={() => {
if (disabled) return;
toggleAgentPlugin(id);
}}
/>
@@ -105,10 +110,13 @@ const AgentPlugin = memo(() => {
{hasDeprecated ? (
<Tooltip title={t('plugin.clearDeprecated')}>
<Button
disabled={disabled}
icon={LucideTrash2}
size={'small'}
onClick={(e) => {
e.stopPropagation();
if (disabled) return;
for (const i of deprecatedList) {
toggleAgentPlugin(i.tag as string);
}
@@ -140,7 +148,7 @@ const AgentPlugin = memo(() => {
description={
<Trans i18nKey={'plugin.empty'} ns={'setting'}>
<Link
<WorkspaceLink
to={'/community/mcp'}
onClick={(e) => {
e.stopPropagation();
@@ -150,7 +158,7 @@ const AgentPlugin = memo(() => {
}}
>
</Link>
</WorkspaceLink>
</Trans>
}
@@ -16,9 +16,13 @@ const AgentPrompt = memo(() => {
const { t } = useTranslation('setting');
const isMobile = useServerConfigStore((s) => s.isMobile);
const [editing, setEditing] = useState(false);
const [systemRole, updateConfig] = useStore((s) => [s.config.systemRole, s.setAgentConfig]);
const [systemRole, disabled, updateConfig] = useStore((s) => [
s.config.systemRole,
s.disabled,
s.setAgentConfig,
]);
const editButton = !editing && !!systemRole && (
const editButton = !editing && !!systemRole && !disabled && (
<Button
icon={PenLineIcon}
iconPlacement={'end'}
@@ -45,10 +49,10 @@ const AgentPrompt = memo(() => {
children: (
<Flexbox paddingBlock={isMobile ? 16 : 0}>
<EditableMessage
showEditWhenEmpty
editing={editing}
height={'auto'}
placeholder={t('settingAgent.prompt.placeholder')}
showEditWhenEmpty={!disabled}
value={systemRole}
variant={'borderless'}
markdownProps={{
@@ -58,10 +62,16 @@ const AgentPrompt = memo(() => {
cancel: t('cancel', { ns: 'common' }),
confirm: t('ok', { ns: 'common' }),
}}
onEditingChange={setEditing}
onChange={(e) => {
if (disabled) return;
updateConfig({ systemRole: e });
}}
onEditingChange={(next) => {
if (disabled) return;
setEditing(next);
}}
/>
{!editing && !!systemRole && <Tokens value={systemRole} />}
</Flexbox>
@@ -16,7 +16,7 @@ import { selectors, useStore } from '../store';
const AgentSelfIteration = memo(() => {
const { t } = useTranslation('setting');
const [form] = Form.useForm();
const updateConfig = useStore((s) => s.setChatConfig);
const [disabled, updateConfig] = useStore((s) => [s.disabled, s.setChatConfig]);
const config = useStore(selectors.currentChatConfig, isEqual);
const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent);
@@ -45,13 +45,18 @@ const AgentSelfIteration = memo(() => {
return (
<Form
disabled={disabled}
footer={isInbox ? undefined : <Form.SubmitFooter />}
form={form}
initialValues={config}
items={[selfIteration]}
itemsType={'group'}
variant={'borderless'}
onFinish={updateConfig}
onFinish={(values) => {
if (disabled) return;
updateConfig(values);
}}
{...FORM_STYLE}
/>
);
+7 -2
View File
@@ -28,7 +28,7 @@ const AgentTTS = memo(() => {
return (all?: boolean) => new VoiceList(all ? undefined : locale);
});
const config = useStore(selectors.currentTtsConfig, isEqual);
const updateConfig = useStore((s) => s.setAgentConfig);
const [disabled, updateConfig] = useStore((s) => [s.disabled, s.setAgentConfig]);
const { edgeVoiceOptions, microsoftVoiceOptions } = useMemo(
() => voiceList(config.showAllLocaleVoice),
@@ -96,6 +96,7 @@ const AgentTTS = memo(() => {
return (
<Form
disabled={disabled}
footer={<Form.SubmitFooter />}
form={form}
items={[tts]}
@@ -104,7 +105,11 @@ const AgentTTS = memo(() => {
initialValues={{
[TTS_SETTING_KEY]: config,
}}
onFinish={updateConfig}
onFinish={(values) => {
if (disabled) return;
updateConfig(values);
}}
{...FORM_STYLE}
/>
);
+3 -2
View File
@@ -10,13 +10,13 @@ import { type State } from './store';
import { useStoreApi } from './store';
export interface StoreUpdaterProps extends Partial<
Pick<State, 'onMetaChange' | 'onConfigChange' | 'meta' | 'config' | 'id' | 'loading'>
Pick<State, 'onMetaChange' | 'onConfigChange' | 'meta' | 'config' | 'disabled' | 'id' | 'loading'>
> {
instanceRef?: ForwardedRef<AgentSettingsInstance> | null;
}
const StoreUpdater = memo<StoreUpdaterProps>(
({ onConfigChange, instanceRef, id, onMetaChange, meta, config, loading }) => {
({ onConfigChange, instanceRef, id, onMetaChange, meta, config, disabled, loading }) => {
const storeApi = useStoreApi();
const useStoreUpdater = createStoreUpdater(storeApi);
@@ -24,6 +24,7 @@ const StoreUpdater = memo<StoreUpdaterProps>(
useStoreUpdater('config', config!);
useStoreUpdater('onConfigChange', onConfigChange);
useStoreUpdater('onMetaChange', onMetaChange);
useStoreUpdater('disabled', disabled);
useStoreUpdater('loading', loading);
useStoreUpdater('id', id);
@@ -8,6 +8,7 @@ export type SaveStatus = 'idle' | 'saving' | 'saved';
export interface State {
config: LobeAgentConfig;
disabled?: boolean;
id?: string;
lastUpdatedTime?: Date | null;
loading?: boolean;
@@ -20,6 +21,7 @@ export interface State {
export const initialState: State = {
config: DEFAULT_AGENT_CONFIG,
disabled: false,
lastUpdatedTime: null,
loading: true,
loadingState: {
+96 -88
View File
@@ -48,108 +48,116 @@ export interface SkillEditFormValues {
}
interface SkillEditFormProps {
disabled?: boolean;
form: FormInstance;
initialValues: SkillEditFormValues;
name?: string;
onSubmit: (values: SkillEditFormValues) => void;
}
const SkillEditForm = memo<SkillEditFormProps>(({ name, form, initialValues, onSubmit }) => {
const { t } = useTranslation('setting');
const editor = useEditor();
const currentValueRef = useRef(initialValues.content);
const SkillEditForm = memo<SkillEditFormProps>(
({ name, disabled, form, initialValues, onSubmit }) => {
const { t } = useTranslation('setting');
const editor = useEditor();
const currentValueRef = useRef(initialValues.content);
useEffect(() => {
form.setFieldsValue(initialValues);
}, [initialValues]);
useEffect(() => {
form.setFieldsValue(initialValues);
}, [initialValues]);
useEffect(() => {
currentValueRef.current = initialValues.content;
}, [initialValues.content]);
useEffect(() => {
currentValueRef.current = initialValues.content;
}, [initialValues.content]);
useEffect(() => {
if (!editor) return;
try {
setTimeout(() => {
if (initialValues.content) {
useEffect(() => {
if (!editor) return;
try {
setTimeout(() => {
if (initialValues.content) {
editor.setDocument('markdown', initialValues.content);
}
}, 100);
} catch {
setTimeout(() => {
editor.setDocument('markdown', initialValues.content);
}
}, 100);
} catch {
setTimeout(() => {
editor.setDocument('markdown', initialValues.content);
}, 100);
}
}, [editor, initialValues.content]);
const handleContentChange = useCallback(
(e: any) => {
const nextContent = (e.getDocument('markdown') as unknown as string) || '';
if (nextContent !== currentValueRef.current) {
currentValueRef.current = nextContent;
form.setFieldValue('content', nextContent);
}, 100);
}
},
[form],
);
}, [editor, initialValues.content]);
const items: FormItemProps[] = [
{
children: <Input disabled readOnly value={name} />,
desc: t('agentSkillEdit.nameDesc'),
label: t('settingAgent.name.title'),
},
{
children: (
<Input.TextArea
autoSize={{ maxRows: 4, minRows: 2 }}
placeholder={t('agentSkillModal.descriptionPlaceholder')}
/>
),
desc: t('agentSkillEdit.descriptionDesc'),
label: t('agentSkillModal.description'),
name: 'description',
},
{
children: (
<div className={styles.editorWrapper}>
<Editor
content={''}
editor={editor}
lineEmptyPlaceholder={t('agentSkillEdit.instructionsPlaceholder')}
placeholder={t('agentSkillEdit.instructionsPlaceholder')}
plugins={PLUGINS}
style={{ paddingBottom: 48 }}
type={'text'}
variant={'chat'}
onTextChange={handleContentChange}
const handleContentChange = useCallback(
(e: any) => {
if (disabled) return;
const nextContent = (e.getDocument('markdown') as unknown as string) || '';
if (nextContent !== currentValueRef.current) {
currentValueRef.current = nextContent;
form.setFieldValue('content', nextContent);
}
},
[disabled, form],
);
const items: FormItemProps[] = [
{
children: <Input disabled readOnly value={name} />,
desc: t('agentSkillEdit.nameDesc'),
label: t('settingAgent.name.title'),
},
{
children: (
<Input.TextArea
autoSize={{ maxRows: 4, minRows: 2 }}
disabled={disabled}
placeholder={t('agentSkillModal.descriptionPlaceholder')}
/>
</div>
),
desc: t('agentSkillEdit.instructionsDesc'),
label: t('agentSkillEdit.instructions'),
},
];
),
desc: t('agentSkillEdit.descriptionDesc'),
label: t('agentSkillModal.description'),
name: 'description',
},
{
children: (
<div
className={styles.editorWrapper}
style={{ pointerEvents: disabled ? 'none' : undefined }}
>
<Editor
content={''}
editor={editor}
lineEmptyPlaceholder={t('agentSkillEdit.instructionsPlaceholder')}
placeholder={t('agentSkillEdit.instructionsPlaceholder')}
plugins={PLUGINS}
style={{ paddingBottom: 48 }}
type={'text'}
variant={'chat'}
onTextChange={handleContentChange}
/>
</div>
),
desc: t('agentSkillEdit.instructionsDesc'),
label: t('agentSkillEdit.instructions'),
},
];
return (
<div className={styles.wrapper}>
<Form
form={form}
gap={0}
initialValues={initialValues}
items={items}
itemsType={'flat'}
layout={'vertical'}
variant={'borderless'}
onFinish={onSubmit}
>
<AForm.Item hidden name="content">
<Input type="hidden" />
</AForm.Item>
</Form>
</div>
);
});
return (
<div className={styles.wrapper}>
<Form
form={form}
gap={0}
initialValues={initialValues}
items={items}
itemsType={'flat'}
layout={'vertical'}
variant={'borderless'}
onFinish={onSubmit}
>
<AForm.Item hidden name="content">
<Input type="hidden" />
</AForm.Item>
</Form>
</div>
);
},
);
SkillEditForm.displayName = 'SkillEditForm';
+10 -1
View File
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
import ContentViewer from '@/features/AgentSkillDetail/ContentViewer';
import FileTree from '@/features/FileTree';
import { usePermission } from '@/hooks/usePermission';
import { useToolStore } from '@/store/tool';
import SkillEditForm, { type SkillEditFormValues } from './SkillEditForm';
@@ -60,6 +61,7 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
const { t: tp } = useTranslation('plugin');
const { t: tc } = useTranslation('common');
const { message } = App.useApp();
const { allowed: canEdit } = usePermission('edit_own_content');
const [selectedFile, setSelectedFile] = useState('SKILL.md');
const [saving, setSaving] = useState(false);
@@ -84,6 +86,7 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
);
const handleSubmit = async (values: SkillEditFormValues) => {
if (!canEdit) return;
setSaving(true);
try {
await updateAgentSkill({
@@ -99,6 +102,7 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
};
const handleDelete = async () => {
if (!canEdit) return;
await deleteAgentSkill(skillId);
message.success(tp('dev.deleteSuccess'));
onClose();
@@ -114,15 +118,19 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
title={tp('dev.confirmDeleteDevPlugin')}
okButtonProps={{
danger: true,
disabled: !canEdit,
type: 'primary',
}}
onConfirm={handleDelete}
>
<Button danger>{tc('delete')}</Button>
<Button danger disabled={!canEdit}>
{tc('delete')}
</Button>
</Popconfirm>
<Flexbox horizontal gap={12}>
<Button onClick={onClose}>{tc('cancel')}</Button>
<Button
disabled={!canEdit}
loading={saving}
type={'primary'}
onClick={() => {
@@ -181,6 +189,7 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
}}
>
<SkillEditForm
disabled={!canEdit}
form={form}
initialValues={initialValues}
name={skillDetail?.name}
@@ -11,6 +11,7 @@ import {
insertFilesIntoEditor,
} from '@/features/EditorCanvas/editorAttachments';
import { useEnterToSend } from '@/hooks/useEnterToSend';
import { usePermission } from '@/hooks/usePermission';
import { useUserAvatar } from '@/hooks/useUserAvatar';
import { useTaskStore } from '@/store/task';
@@ -18,6 +19,7 @@ import { styles } from '../shared/style';
const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
const { t } = useTranslation('chat');
const { allowed: canEditTask } = usePermission('create_content');
const editor = useEditor();
const addComment = useTaskStore((s) => s.addComment);
const userAvatar = useUserAvatar();
@@ -46,7 +48,7 @@ const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
);
const handleSubmit = useCallback(async () => {
if (submitting) return;
if (!canEditTask || submitting) return;
const json = editor?.getDocument?.('json') as unknown;
const markdown = String(editor?.getDocument?.('markdown') ?? '').trim();
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
@@ -61,7 +63,7 @@ const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
} finally {
setSubmitting(false);
}
}, [taskId, editor, addComment, submitting]);
}, [canEditTask, taskId, editor, addComment, submitting]);
return (
<Flexbox className={styles.commentInputCard} gap={6}>
@@ -75,6 +77,7 @@ const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
style={{ fontSize: 14, minHeight: 24, paddingBlock: 0 }}
onContentChange={handleContentChange}
onPressEnter={({ event }) => {
if (!canEditTask) return true;
if (shouldSendOnEnter(event)) {
handleSubmit();
return true;
@@ -85,7 +88,7 @@ const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
<Flexbox horizontal align={'center'} gap={4} style={{ flexShrink: 0 }}>
<AttachmentUploadButton onFiles={handleAttach} />
<SendButton
disabled={!canSubmit && !submitting}
disabled={!canEditTask || (!canSubmit && !submitting)}
loading={submitting}
shape={'round'}
type={'text'}
@@ -0,0 +1,92 @@
/**
* @vitest-environment happy-dom
*/
import { render } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TaskDetailHeaderActions from './TaskDetailHeaderActions';
interface MenuItem {
key?: string;
label?: ReactNode;
type?: string;
}
const mocks = vi.hoisted(() => ({
deleteTask: vi.fn(),
dropdownItems: [] as MenuItem[],
messageSuccess: vi.fn(),
modalConfirm: vi.fn(),
navigate: vi.fn(),
taskState: {
activeTaskId: 'T-1' as string | undefined,
taskDetailMap: {} as Record<string, unknown>,
},
transferItems: [
{ key: 'transfer-task', label: 'Transfer to...' },
{ key: 'copy-task', label: 'Copy to...' },
] as MenuItem[],
}));
vi.mock('@lobehub/ui', () => ({
ActionIcon: ({ title }: { title?: string }) => <button type="button">{title}</button>,
DropdownMenu: ({ children, items }: { children?: ReactNode; items: MenuItem[] }) => {
mocks.dropdownItems = items;
return <>{children}</>;
},
Icon: () => <span />,
copyToClipboard: vi.fn(),
}));
vi.mock('antd', () => ({
App: {
useApp: () => ({
message: { success: mocks.messageSuccess },
modal: { confirm: mocks.modalConfirm },
}),
},
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@/business/client/hooks/useTaskTransferMenuItem', () => ({
useTaskTransferMenuItem: vi.fn(() => mocks.transferItems),
}));
vi.mock('@/features/Workspace/useWorkspaceAwareNavigate', () => ({
useWorkspaceAwareNavigate: () => mocks.navigate,
}));
vi.mock('@/hooks/useAppOrigin', () => ({
useAppOrigin: () => 'https://example.com',
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({ allowed: true }),
}));
vi.mock('@/store/task', () => ({
useTaskStore: (
selector: (state: { activeTaskId?: string; deleteTask: typeof mocks.deleteTask }) => unknown,
) => selector({ ...mocks.taskState, deleteTask: mocks.deleteTask }),
}));
describe('TaskDetailHeaderActions', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.dropdownItems = [];
mocks.taskState.activeTaskId = 'T-1';
});
it('includes task transfer and copy actions in the detail menu', () => {
render(<TaskDetailHeaderActions />);
expect(mocks.dropdownItems.map((item) => item?.key)).toContain('transfer-task');
expect(mocks.dropdownItems.map((item) => item?.key)).toContain('copy-task');
});
});
@@ -4,9 +4,11 @@ import { App } from 'antd';
import { CopyIcon, LinkIcon, MoreHorizontal, Trash } from 'lucide-react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useTaskTransferMenuItem } from '@/business/client/hooks/useTaskTransferMenuItem';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import { usePermission } from '@/hooks/usePermission';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
@@ -15,13 +17,16 @@ import { taskDetailPath } from '../shared/taskDetailPath';
const TaskDetailHeaderActions = memo(() => {
const { t } = useTranslation(['chat', 'common']);
const { message } = App.useApp();
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const appOrigin = useAppOrigin();
const { allowed: canEditTask } = usePermission('create_content');
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
const taskAgentId = useTaskStore(taskDetailSelectors.activeTaskAgentId);
const deleteTask = useTaskStore((s) => s.deleteTask);
const transferItems = useTaskTransferMenuItem(taskId) as DropdownItem[] | null;
const triggerDelete = useCallback(() => {
if (!canEditTask) return;
if (!taskId) return;
confirmModal({
content: t('taskDetail.deleteConfirm.content'),
@@ -33,14 +38,14 @@ const TaskDetailHeaderActions = memo(() => {
},
title: t('taskDetail.deleteConfirm.title'),
});
}, [taskId, t, deleteTask, navigate]);
}, [canEditTask, taskId, t, deleteTask, navigate]);
const menuItems = useMemo<DropdownItem[]>(() => {
if (!taskId) return [];
const taskUrl = `${appOrigin}${taskDetailPath(taskId, taskAgentId ?? undefined)}`;
return [
const baseItems: DropdownItem[] = [
{
icon: <Icon icon={CopyIcon} />,
key: 'copyId',
@@ -62,13 +67,18 @@ const TaskDetailHeaderActions = memo(() => {
{ type: 'divider' },
{
danger: true,
disabled: !canEditTask,
icon: <Icon icon={Trash} />,
key: 'delete',
label: t('delete', { ns: 'common' }),
onClick: triggerDelete,
},
];
}, [taskId, taskAgentId, appOrigin, t, message, triggerDelete]);
if (!transferItems || transferItems.length === 0) return baseItems;
return [...baseItems.slice(0, 3), ...transferItems, { type: 'divider' }, ...baseItems.slice(3)];
}, [taskId, taskAgentId, appOrigin, t, message, triggerDelete, canEditTask, transferItems]);
if (!taskId) return null;
@@ -5,6 +5,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import StopLoadingIcon from '@/components/StopLoading';
import { usePermission } from '@/hooks/usePermission';
import { useAgentStore } from '@/store/agent';
import { builtinAgentSelectors } from '@/store/agent/selectors';
import { useTaskStore } from '@/store/task';
@@ -25,6 +26,7 @@ const formatCountdown = (msRemaining: number): string => {
const TaskDetailRunPauseAction = memo(() => {
const { t } = useTranslation('chat');
const { allowed: canEditTask, reason } = usePermission('create_content');
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
const canRun = useTaskStore(taskDetailSelectors.canRunActiveTask);
const canPause = useTaskStore(taskDetailSelectors.canPauseActiveTask);
@@ -47,6 +49,7 @@ const TaskDetailRunPauseAction = memo(() => {
const [isRunningNow, setIsRunningNow] = useState(false);
const handleRunOrPause = useCallback(async () => {
if (!canEditTask) return;
if (!taskId) return;
if (canPause) {
await updateTaskStatus(taskId, 'paused');
@@ -71,9 +74,11 @@ const TaskDetailRunPauseAction = memo(() => {
runTask,
updateTask,
updateTaskStatus,
canEditTask,
]);
const handleRunNow = useCallback(async () => {
if (!canEditTask) return;
if (!taskId) return;
setIsRunningNow(true);
try {
@@ -84,9 +89,10 @@ const TaskDetailRunPauseAction = memo(() => {
} finally {
setIsRunningNow(false);
}
}, [taskId, assigneeAgentId, inboxAgentId, runTask, updateTask]);
}, [canEditTask, taskId, assigneeAgentId, inboxAgentId, runTask, updateTask]);
const handleCancelSchedule = useCallback(async () => {
if (!canEditTask) return;
if (!taskId) return;
setIsCancellingSchedule(true);
try {
@@ -97,7 +103,7 @@ const TaskDetailRunPauseAction = memo(() => {
} finally {
setIsCancellingSchedule(false);
}
}, [taskId, setAutomationMode, updateTaskStatus, status]);
}, [canEditTask, taskId, setAutomationMode, updateTaskStatus, status]);
const isScheduled = status === 'scheduled';
@@ -133,9 +139,10 @@ const TaskDetailRunPauseAction = memo(() => {
<Flexbox horizontal align={'center'} gap={12}>
<Space.Compact>
<Button
disabled={isRunningNow}
disabled={!canEditTask || isRunningNow}
icon={CalendarOffIcon}
loading={isCancellingSchedule}
title={canEditTask ? undefined : reason}
onClick={handleCancelSchedule}
>
{t('taskDetail.cancelSchedule')}
@@ -143,7 +150,7 @@ const TaskDetailRunPauseAction = memo(() => {
<DropdownMenu
items={[
{
disabled: isRunningNow || isCancellingSchedule,
disabled: !canEditTask || isRunningNow || isCancellingSchedule,
icon: PlayIcon,
key: 'runNow',
label: t('taskDetail.runNow'),
@@ -151,7 +158,12 @@ const TaskDetailRunPauseAction = memo(() => {
},
]}
>
<Button disabled={isCancellingSchedule} icon={ChevronDown} loading={isRunningNow} />
<Button
disabled={!canEditTask || isCancellingSchedule}
icon={ChevronDown}
loading={isRunningNow}
title={canEditTask ? undefined : reason}
/>
</DropdownMenu>
</Space.Compact>
{countdownText && (
@@ -176,7 +188,12 @@ const TaskDetailRunPauseAction = memo(() => {
if (canPause) {
return (
<Button icon={StopLoadingIcon} onClick={handleRunOrPause}>
<Button
disabled={!canEditTask}
icon={StopLoadingIcon}
title={reason}
onClick={handleRunOrPause}
>
{t('taskDetail.stopTask')}
</Button>
);
@@ -186,7 +203,13 @@ const TaskDetailRunPauseAction = memo(() => {
const runIcon = isRerun ? RotateCcwIcon : PlayIcon;
return (
<Button icon={runIcon} type={'primary'} onClick={handleRunOrPause}>
<Button
disabled={!canEditTask}
icon={runIcon}
title={canEditTask ? undefined : reason}
type={'primary'}
onClick={handleRunOrPause}
>
{runLabel}
</Button>
);
@@ -3,6 +3,7 @@ import { Input } from 'antd';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePermission } from '@/hooks/usePermission';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
@@ -12,6 +13,7 @@ const DEBOUNCE_MS = 300;
const TaskDetailTitleInput = memo(() => {
const { t } = useTranslation('chat');
const { allowed: canEditTask } = usePermission('create_content');
const name = useTaskStore(taskDetailSelectors.activeTaskName);
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
const updateTask = useTaskStore((s) => s.updateTask);
@@ -24,6 +26,7 @@ const TaskDetailTitleInput = memo(() => {
const { run: debouncedSave } = useDebounceFn(
(value: string) => {
if (!canEditTask) return;
if (taskId) updateTask(taskId, { name: value });
},
{ wait: DEBOUNCE_MS },
@@ -41,6 +44,7 @@ const TaskDetailTitleInput = memo(() => {
<Input.TextArea
autoSize={{ minRows: 1 }}
className={styles.titleInput}
disabled={!canEditTask}
placeholder={t('taskDetail.titlePlaceholder')}
value={localName}
variant={'borderless'}
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { EditorCanvas } from '@/features/EditorCanvas';
import { seedAttachments } from '@/features/EditorCanvas/attachmentRegistry';
import { pickAndInsertAttachments } from '@/features/EditorCanvas/editorAttachments';
import { usePermission } from '@/hooks/usePermission';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
@@ -14,6 +15,7 @@ const DEBOUNCE_MS = 300;
const TaskInstruction = memo(() => {
const { t } = useTranslation('chat');
const { allowed: canEditTask } = usePermission('create_content');
const instruction = useTaskStore(taskDetailSelectors.activeTaskInstruction);
const persistedEditorData = useTaskStore(taskDetailSelectors.activeTaskEditorData);
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
@@ -47,6 +49,7 @@ const TaskInstruction = memo(() => {
}, [taskId]);
const handleContentChange = useCallback(() => {
if (!canEditTask) return;
if (!editor || !taskId) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -61,7 +64,7 @@ const TaskInstruction = memo(() => {
console.error('[TaskInstruction] Failed to save:', e);
});
}, DEBOUNCE_MS);
}, [editor, taskId, updateTask]);
}, [canEditTask, editor, taskId, updateTask]);
const handleAttach = useCallback(() => {
pickAndInsertAttachments(editor);
@@ -1,12 +1,14 @@
import { memo, useCallback } from 'react';
import ModelSelect from '@/features/ModelSelect';
import { usePermission } from '@/hooks/usePermission';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
const TaskModelConfig = memo(() => {
const { allowed: canEditTask } = usePermission('create_content');
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
const taskModel = useTaskStore(taskDetailSelectors.activeTaskModel);
const taskProvider = useTaskStore(taskDetailSelectors.activeTaskProvider);
@@ -20,15 +22,17 @@ const TaskModelConfig = memo(() => {
const handleChange = useCallback(
async (params: { model: string; provider: string }) => {
if (!canEditTask) return;
if (!taskId) return;
await updateTaskModelConfig(taskId, params);
},
[taskId, updateTaskModelConfig],
[canEditTask, taskId, updateTaskModelConfig],
);
return (
<ModelSelect
initialWidth
disabled={!canEditTask}
popupWidth={400}
value={{ model, provider }}
onChange={handleChange}
@@ -2,8 +2,8 @@ import type { TaskDetailData, TaskDetailSubtask } from '@lobechat/types';
import { Button, Flexbox, Text } from '@lobehub/ui';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { taskService } from '@/services/task';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
@@ -28,7 +28,7 @@ const toTaskStatus = (status?: string): TaskStatus =>
const TaskParentBar = memo(() => {
const { t } = useTranslation('chat');
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const parent = useTaskStore(taskDetailSelectors.activeTaskParent);
const currentIdentifier = useTaskStore(taskDetailSelectors.activeTaskDetail)?.identifier;
@@ -19,6 +19,7 @@ import type { ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePermission } from '@/hooks/usePermission';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
@@ -55,10 +56,11 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
interface IntervalTabProps {
currentInterval: number;
disabled?: boolean;
taskId?: string;
}
const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
const IntervalTab = memo<IntervalTabProps>(({ currentInterval, disabled, taskId }) => {
const { t } = useTranslation('chat');
const updatePeriodicInterval = useTaskStore((s) => s.updatePeriodicInterval);
@@ -98,23 +100,25 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
normalized = val;
}
setLocalValue(normalized);
if (disabled) return;
if (!taskId) return;
const seconds = toSeconds(normalized ?? null, localUnit);
updatePeriodicInterval(taskId, seconds);
},
[taskId, localUnit, updatePeriodicInterval],
[disabled, taskId, localUnit, updatePeriodicInterval],
);
const handleUnitChange = useCallback(
(u: IntervalUnit) => {
setLocalUnit(u);
if (disabled) return;
if (!taskId || !localValue) return;
const clamped = u === 'minutes' ? Math.max(MIN_MINUTES, localValue) : localValue;
if (clamped !== localValue) setLocalValue(clamped);
const seconds = toSeconds(clamped, u);
updatePeriodicInterval(taskId, seconds);
},
[taskId, localValue, updatePeriodicInterval],
[disabled, taskId, localValue, updatePeriodicInterval],
);
return (
@@ -123,6 +127,7 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
<Flexbox horizontal align="center" gap={8}>
<Text type="secondary">{t('taskSchedule.every')}</Text>
<InputNumber
disabled={disabled}
min={localUnit === 'minutes' ? MIN_MINUTES : 1}
placeholder={localUnit === 'minutes' ? String(MIN_MINUTES) : '1'}
style={{ width: 100 }}
@@ -131,6 +136,7 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
onChange={handleValueChange}
/>
<Select
disabled={disabled}
style={{ flex: 1 }}
value={localUnit}
variant="filled"
@@ -147,10 +153,11 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
});
interface SchedulerTabProps {
disabled?: boolean;
taskId?: string;
}
const SchedulerTab = memo<SchedulerTabProps>(({ taskId }) => {
const SchedulerTab = memo<SchedulerTabProps>(({ disabled, taskId }) => {
const updateSchedule = useTaskStore((s) => s.updateSchedule);
const pattern = useTaskStore(taskDetailSelectors.activeTaskSchedulePattern);
const timezone = useTaskStore(taskDetailSelectors.activeTaskScheduleTimezone);
@@ -158,10 +165,11 @@ const SchedulerTab = memo<SchedulerTabProps>(({ taskId }) => {
const handleChange = useCallback(
(change: SchedulerFormChange) => {
if (disabled) return;
if (!taskId) return;
updateSchedule(taskId, change);
},
[taskId, updateSchedule],
[disabled, taskId, updateSchedule],
);
return (
@@ -187,6 +195,7 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
taskId,
}: TaskScheduleConfigProps) {
const { t, i18n } = useTranslation('chat');
const { allowed: canEditTask, reason } = usePermission('create_content');
const activeTaskId = useTaskStore(taskDetailSelectors.activeTaskId);
const activeTaskInterval = useTaskStore(taskDetailSelectors.activeTaskPeriodicInterval);
const automationMode = useTaskStore(taskDetailSelectors.activeTaskAutomationMode);
@@ -263,23 +272,26 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
const handleEnableChange = useCallback(
(checked: boolean) => {
if (!canEditTask) return;
if (!finalTaskId) return;
// Schedule (cron) is the more common, predictable choice; users who want
// a fixed interval can switch to the heartbeat tab from there.
setAutomationMode(finalTaskId, checked ? 'schedule' : null);
},
[finalTaskId, setAutomationMode],
[canEditTask, finalTaskId, setAutomationMode],
);
const handleModeChange = useCallback(
(value: string | number) => {
if (!canEditTask) return;
if (!finalTaskId) return;
setAutomationMode(finalTaskId, value as TaskAutomationMode);
},
[finalTaskId, setAutomationMode],
[canEditTask, finalTaskId, setAutomationMode],
);
const handleStartScheduling = useCallback(async () => {
if (!canEditTask) return;
if (!finalTaskId) return;
setIsStartingSchedule(true);
try {
@@ -287,7 +299,7 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
} finally {
setIsStartingSchedule(false);
}
}, [finalTaskId, updateTaskStatus]);
}, [canEditTask, finalTaskId, updateTaskStatus]);
const content = (
<Flexbox gap={16} style={{ padding: 4, width: 440 }} onClick={(e) => e.stopPropagation()}>
@@ -309,7 +321,7 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
</Text>
)}
</Flexbox>
<Switch checked={enabled} onChange={handleEnableChange} />
<Switch checked={enabled} disabled={!canEditTask} onChange={handleEnableChange} />
</Flexbox>
{enabled && nextRunText && (
@@ -326,6 +338,7 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
<>
<Segmented
block
disabled={!canEditTask}
value={automationMode ?? 'heartbeat'}
options={[
{
@@ -350,12 +363,19 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
onChange={handleModeChange}
/>
{automationMode === 'heartbeat' && (
<IntervalTab currentInterval={finalCurrentInterval} taskId={finalTaskId} />
<IntervalTab
currentInterval={finalCurrentInterval}
disabled={!canEditTask}
taskId={finalTaskId}
/>
)}
{automationMode === 'schedule' && (
<SchedulerTab disabled={!canEditTask} taskId={finalTaskId} />
)}
{automationMode === 'schedule' && <SchedulerTab taskId={finalTaskId} />}
{canStartSchedule && (
<Button
block
disabled={!canEditTask}
icon={CalendarClockIcon}
loading={isStartingSchedule}
type="primary"
@@ -370,11 +390,20 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
);
return (
<Popover className={styles.popover} content={content} placement="bottomRight" trigger="click">
<Popover
className={styles.popover}
content={content}
disabled={!canEditTask}
placement="bottomRight"
trigger="click"
>
{children ? (
<div onClick={(e) => e.stopPropagation()}>{children}</div>
<div title={canEditTask ? undefined : reason} onClick={(e) => e.stopPropagation()}>
{children}
</div>
) : (
<ActionIcon
disabled={!canEditTask}
icon={TimerIcon}
size="small"
title={t('taskSchedule.title')}
@@ -8,8 +8,9 @@ import { ChevronDown, ListTodoIcon, PlayCircle, Plus } from 'lucide-react';
import type { Key, MouseEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { usePermission } from '@/hooks/usePermission';
import { taskService } from '@/services/task';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
@@ -129,7 +130,8 @@ const toTreeData = (tree: TaskTreeNode[]): DataNode[] => {
const TaskSubtasks = memo(() => {
const { t } = useTranslation('chat');
const { message } = App.useApp();
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const { allowed: canEditTask, reason } = usePermission('create_content');
const agentId = useTaskStore(taskDetailSelectors.activeTaskAgentId);
const subtasks = useTaskStore(taskDetailSelectors.activeTaskSubtasks);
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
@@ -168,6 +170,7 @@ const TaskSubtasks = memo(() => {
const handleRightClick = useCallback(
({ event, node }: { event: MouseEvent; node: { key: Key } }) => {
if (!canEditTask) return;
const subtask = subtaskMap.get(String(node.key));
if (!subtask) return;
event.preventDefault();
@@ -186,12 +189,16 @@ const TaskSubtasks = memo(() => {
status: subtask.status,
});
},
[subtaskMap, buildItems, installKeyboardHandlers],
[canEditTask, subtaskMap, buildItems, installKeyboardHandlers],
);
const toggleCreating = useCallback(() => setIsCreating((prev) => !prev), []);
const toggleCreating = useCallback(() => {
if (!canEditTask) return;
setIsCreating((prev) => !prev);
}, [canEditTask]);
const handleRunAll = useCallback(async () => {
if (!canEditTask) return;
if (!taskId || isPlanning) return;
setIsPlanning(true);
try {
@@ -241,7 +248,7 @@ const TaskSubtasks = memo(() => {
} finally {
setIsPlanning(false);
}
}, [taskId, isPlanning, message, t, runReadySubtasks]);
}, [canEditTask, taskId, isPlanning, message, t, runReadySubtasks]);
if (!taskId) return null;
@@ -281,17 +288,18 @@ const TaskSubtasks = memo(() => {
</Flexbox>
<Flexbox horizontal align="center" gap={4}>
<ActionIcon
disabled={isPlanning}
disabled={!canEditTask || isPlanning}
icon={PlayCircle}
loading={isPlanning}
size="small"
title={t('taskDetail.runAll')}
title={canEditTask ? t('taskDetail.runAll') : reason}
onClick={handleRunAll}
/>
<ActionIcon
disabled={!canEditTask}
icon={Plus}
size="small"
title={t('taskDetail.addSubtask')}
title={canEditTask ? t('taskDetail.addSubtask') : reason}
onClick={toggleCreating}
/>
</Flexbox>
@@ -337,6 +345,7 @@ const TaskSubtasks = memo(() => {
paddingBlock={4}
paddingInline={8}
style={{ width: 'fit-content' }}
title={canEditTask ? undefined : reason}
variant="borderless"
onClick={toggleCreating}
>
@@ -15,6 +15,10 @@ const mocks = vi.hoisted(() => ({
dbMessagesMap: {} as Record<string, unknown[]>,
replaceMessages: vi.fn(),
},
permission: {
allowed: true,
reason: 'requires member',
},
serverConfigState: {
serverConfig: {
enableBusinessFeatures: false,
@@ -49,10 +53,38 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock('@lobehub/ui', () => ({
ActionIcon: ({ onClick }: { onClick?: () => void }) => <button onClick={onClick} />,
ActionIcon: ({
disabled,
onClick,
title,
}: {
disabled?: boolean;
onClick?: () => void;
title?: string;
}) => (
<button disabled={disabled} title={title} onClick={onClick}>
{title}
</button>
),
copyToClipboard: vi.fn(),
Drawer: ({ children, open }: { children?: ReactNode; open?: boolean }) =>
open ? <div data-testid="topic-drawer">{children}</div> : null,
Drawer: ({
children,
extra,
open,
title,
}: {
children?: ReactNode;
extra?: ReactNode;
open?: boolean;
title?: ReactNode;
}) =>
open ? (
<div data-testid="topic-drawer">
{title}
{extra}
{children}
</div>
) : null,
DropdownMenu: ({ children }: { children?: ReactNode }) => <>{children}</>,
Flexbox: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Freeze: ({ children }: { children?: ReactNode; frozen?: boolean }) => <>{children}</>,
@@ -102,6 +134,10 @@ vi.mock('@/hooks/useOperationState', () => ({
useOperationState: () => undefined,
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({ allowed: mocks.permission.allowed, reason: mocks.permission.reason }),
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (state: typeof mocks.agentState) => unknown) =>
selector(mocks.agentState),
@@ -142,6 +178,8 @@ describe('TopicChatDrawer', () => {
mocks.chatState.replaceMessages.mockClear();
mocks.taskState.closeTopicDrawer.mockClear();
mocks.taskState.activeTopicDrawerTopicId = 'topic-1';
mocks.permission.allowed = true;
mocks.serverConfigState.serverConfig.enableBusinessFeatures = false;
});
it('hydrates the task assignee agent config for drawer messages', () => {
@@ -149,4 +187,13 @@ describe('TopicChatDrawer', () => {
expect(mocks.agentState.useHydrateAgentConfig).toHaveBeenCalledWith(true, 'agt_assignee');
});
it('disables topic sharing for workspace viewers', () => {
mocks.permission.allowed = false;
mocks.serverConfigState.serverConfig.enableBusinessFeatures = true;
const { getByTitle } = render(<TopicChatDrawer />);
expect(getByTitle('requires member')).toBeDisabled();
});
});
@@ -22,6 +22,7 @@ import { useShareModal } from '@/features/ShareModal';
import { LazySharePopover as SharePopover } from '@/features/SharePopover/lazy';
import { useGatewayReconnect } from '@/hooks/useGatewayReconnect';
import { useOperationState } from '@/hooks/useOperationState';
import { usePermission } from '@/hooks/usePermission';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
@@ -114,6 +115,7 @@ const TopicChatDrawer = memo(() => {
const closeTopicDrawer = useTaskStore((s) => s.closeTopicDrawer);
const useFetchTaskDetail = useTaskStore((s) => s.useFetchTaskDetail);
const enableTopicLinkShare = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
const { allowed: canShare, reason } = usePermission('edit_own_content');
// Hydrate task detail when the drawer is opened outside of TaskDetailPage
// (e.g. from a brief on home) so the header has agentId / status / seq.
@@ -175,15 +177,16 @@ const TopicChatDrawer = memo(() => {
const shareIcon = (
<ActionIcon
disabled={!canShare}
icon={Share2}
size={'small'}
title={t('share', { ns: 'common' })}
onClick={enableTopicLinkShare ? undefined : openShareModal}
title={canShare ? t('share', { ns: 'common' }) : reason}
onClick={enableTopicLinkShare || !canShare ? undefined : openShareModal}
/>
);
const extra = topicId ? (
enableTopicLinkShare ? (
enableTopicLinkShare && canShare ? (
<SharePopover topicId={topicId} onOpenModal={openShareModal}>
{shareIcon}
</SharePopover>
@@ -1,8 +1,31 @@
import { describe, expect, it } from 'vitest';
import { getTaskCreateActionBehavior } from './AgentTasksPage';
import { shouldRenderTaskAgentPanelToggle } from './taskAgentPanelToggle';
describe('AgentTasksPage', () => {
describe('getTaskCreateActionBehavior', () => {
it('should allow workspace viewers to reopen the collapsed inline entry in list view', () => {
expect(
getTaskCreateActionBehavior({
canCreateTask: false,
inlineCollapsed: true,
viewMode: 'list',
}),
).toEqual({ disabled: false, mode: 'inline' });
});
it('should keep the modal create action disabled for workspace viewers in kanban view', () => {
expect(
getTaskCreateActionBehavior({
canCreateTask: false,
inlineCollapsed: false,
viewMode: 'kanban',
}),
).toEqual({ disabled: true, mode: 'modal' });
});
});
describe('shouldRenderTaskAgentPanelToggle', () => {
it('should render the task agent panel toggle on desktop layouts', () => {
expect(shouldRenderTaskAgentPanelToggle(false)).toBe(true);
@@ -1,17 +1,19 @@
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { Plus } from 'lucide-react';
import { memo, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@/const/layoutTokens';
import NavHeader from '@/features/NavHeader';
import ToggleRightPanelButton from '@/features/RightPanel/ToggleRightPanelButton';
import WideScreenContainer from '@/features/WideScreenContainer';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { useIsMobile } from '@/hooks/useIsMobile';
import { usePermission } from '@/hooks/usePermission';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useTaskStore } from '@/store/task';
import { taskListSelectors } from '@/store/task/selectors';
import type { TaskViewMode } from '@/store/task/slices/list/initialState';
import { createTaskModal } from '../CreateTaskModal';
import Breadcrumb from '../shared/Breadcrumb';
@@ -25,9 +27,29 @@ import { shouldRenderTaskAgentPanelToggle } from './taskAgentPanelToggle';
import TaskList from './TaskList';
import TasksGroupConfig from './TasksGroupConfig';
interface TaskCreateActionBehaviorParams {
canCreateTask: boolean;
inlineCollapsed: boolean;
viewMode: TaskViewMode;
}
export const getTaskCreateActionBehavior = ({
canCreateTask,
inlineCollapsed,
viewMode,
}: TaskCreateActionBehaviorParams) => {
const shouldExpandInline = inlineCollapsed && viewMode === 'list';
return {
disabled: shouldExpandInline ? false : !canCreateTask,
mode: shouldExpandInline ? 'inline' : 'modal',
} as const;
};
const AgentTasksPage = memo(() => {
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const isMobile = useIsMobile();
const { allowed: canCreateTask, reason } = usePermission('create_content');
const viewMode = useTaskStore(taskListSelectors.viewMode);
const useFetchTaskList = useTaskStore((s) => s.useFetchTaskList);
useFetchTaskList({ allAgents: true });
@@ -48,13 +70,29 @@ const AgentTasksPage = memo(() => {
[updateSystemStatus, viewOptions],
);
const createActionBehavior = useMemo(
() =>
getTaskCreateActionBehavior({
canCreateTask,
inlineCollapsed,
viewMode,
}),
[canCreateTask, inlineCollapsed, viewMode],
);
const handleCreateTask = useCallback(() => {
if (createActionBehavior.mode === 'inline') {
updateSystemStatus({ taskCreateInlineCollapsed: false }, 'expandTaskCreateInline');
return;
}
if (!canCreateTask) return;
createTaskModal({
onCreated: (task) => {
navigate(taskDetailPath(task.identifier, task.agentId));
},
});
}, [navigate]);
}, [canCreateTask, createActionBehavior.mode, navigate, updateSystemStatus]);
const handleShowHiddenCompleted = useCallback(() => {
setViewOptions((prev) => ({ ...prev, hideCompleted: false }));
@@ -70,8 +108,10 @@ const AgentTasksPage = memo(() => {
<Flexbox horizontal align={'center'} gap={4}>
{(inlineCollapsed || viewMode === 'kanban') && (
<ActionIcon
disabled={createActionBehavior.disabled}
icon={Plus}
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
title={createActionBehavior.disabled ? reason : undefined}
onClick={handleCreateTask}
/>
)}
@@ -0,0 +1,88 @@
/**
* @vitest-environment happy-dom
*/
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import CreateTaskInlineEntry from './CreateTaskInlineEntry';
const permissionMock = vi.hoisted(() => ({
allowed: true,
}));
const focusMock = vi.hoisted(() => vi.fn());
vi.mock('@lobehub/editor/react', () => ({
useEditor: () => ({
cleanDocument: vi.fn(),
focus: focusMock,
getLexicalEditor: () => undefined,
}),
}));
vi.mock('@/features/EditorCanvas', () => ({
EditorCanvas: ({ disabled }: { disabled?: boolean }) => (
<div data-disabled={String(!!disabled)} data-testid="task-editor" />
),
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
allowed: permissionMock.allowed,
reason: permissionMock.allowed ? '' : 'requires member',
}),
}));
vi.mock('@/store/task', () => ({
useTaskStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
createTask: vi.fn(),
isCreatingTask: false,
}),
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
updateSystemStatus: vi.fn(),
}),
}));
vi.mock('../features/TaskPriorityTag', () => ({
default: ({ children }: { children?: ReactNode }) => (
<div data-testid="priority">{children ?? 'priority'}</div>
),
}));
vi.mock('../features/AssigneeAgentSelector', () => ({
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock('../features/AssigneeAvatar', () => ({
default: () => <div />,
}));
vi.mock('../shared/useAgentDisplayMeta', () => ({
useAgentDisplayMeta: () => undefined,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
describe('CreateTaskInlineEntry', () => {
beforeEach(() => {
permissionMock.allowed = true;
focusMock.mockReset();
});
it('renders the task editor as disabled when the user cannot create content', () => {
permissionMock.allowed = false;
render(<CreateTaskInlineEntry variant="hero" />);
expect(screen.getByTestId('task-editor')).toHaveAttribute('data-disabled', 'true');
expect(focusMock).not.toHaveBeenCalled();
});
});
@@ -14,6 +14,7 @@ import {
getAttachmentFileIdsFromEditor,
pickAndInsertAttachments,
} from '@/features/EditorCanvas/editorAttachments';
import { usePermission } from '@/hooks/usePermission';
import { useGlobalStore } from '@/store/global';
import { useTaskStore } from '@/store/task';
@@ -48,6 +49,7 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
} = props;
const isHero = variant === 'hero';
const { t } = useTranslation('chat');
const { allowed: canCreateTask, reason } = usePermission('create_content');
const createTask = useTaskStore((s) => s.createTask);
const isCreating = useTaskStore((s) => s.isCreatingTask);
@@ -63,8 +65,9 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
const assigneeMeta = useAgentDisplayMeta(assigneeAgentId);
useEffect(() => {
if (!canCreateTask) return;
if (autoFocus || isHero) editor?.focus?.();
}, [autoFocus, editor, isHero]);
}, [autoFocus, canCreateTask, editor, isHero]);
const handleCollapse = useCallback(() => {
if (onCollapse) {
@@ -75,19 +78,21 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
}, [onCollapse, updateSystemStatus]);
const handleContentChange = useCallback(() => {
if (!canCreateTask) return;
const lexicalEditor = editor?.getLexicalEditor?.();
if (!lexicalEditor) return;
lexicalEditor.getEditorState().read(() => {
setInstruction($getRoot().getTextContent());
});
setHasAttachments(getAttachmentFileIdsFromEditor(editor).length > 0);
}, [editor]);
}, [canCreateTask, editor]);
const handleAttach = useCallback(() => {
pickAndInsertAttachments(editor);
}, [editor]);
const handleSubmit = useCallback(async () => {
if (!canCreateTask) return;
const markdown = String(editor?.getDocument?.('markdown') ?? '').trim();
const trimmedText = instruction.trim();
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
@@ -133,6 +138,7 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
onCreated,
parentTaskId,
priority,
canCreateTask,
]);
const handleSubmitRef = useRef(handleSubmit);
@@ -170,6 +176,7 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
}}
>
<EditorCanvas
disabled={!canCreateTask}
editor={editor}
floatingToolbar={false}
placeholder={placeholder ?? t('createTask.instructionPlaceholder')}
@@ -248,10 +255,11 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
</Flexbox>
<Button
disabled={isCreating || (!instruction.trim() && !hasAttachments)}
disabled={!canCreateTask || isCreating || (!instruction.trim() && !hasAttachments)}
loading={isCreating}
shape={'round'}
size={'small'}
title={canCreateTask ? undefined : reason}
type={'primary'}
onClick={handleSubmit}
>
@@ -14,8 +14,9 @@ import { createStaticStyles } from 'antd-style';
import { ClipboardCheckIcon } from 'lucide-react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { usePermission } from '@/hooks/usePermission';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useTaskStore } from '@/store/task';
@@ -75,7 +76,8 @@ const optimisticMoveTask = (
const KanbanBoard = memo(() => {
const { t } = useTranslation('chat');
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const { allowed: canEditTask } = usePermission('create_content');
const useFetchTaskGroupList = useTaskStore((s) => s.useFetchTaskGroupList);
useFetchTaskGroupList({ allAgents: true });
@@ -95,14 +97,19 @@ const KanbanBoard = memo(() => {
useSensor(KeyboardSensor),
);
const handleDragStart = useCallback((event: DragStartEvent) => {
const task = event.active.data.current?.task as TaskListItem | undefined;
setActiveTask(task ?? null);
}, []);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
if (!canEditTask) return;
const task = event.active.data.current?.task as TaskListItem | undefined;
setActiveTask(task ?? null);
},
[canEditTask],
);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
setActiveTask(null);
if (!canEditTask) return;
const { active, over } = event;
if (!over) return;
@@ -126,7 +133,7 @@ const KanbanBoard = memo(() => {
useTaskStore.setState({ taskGroups: prevGroups }, false, 'kanban/revertMove');
}
},
[updateTaskStatus],
[canEditTask, updateTaskStatus],
);
const handleDragCancel = useCallback(() => {
@@ -134,13 +141,14 @@ const KanbanBoard = memo(() => {
}, []);
const handleCreateTask = useCallback(() => {
if (!canEditTask) return;
createTaskModal({
onCreated: (task) => {
navigate(taskDetailPath(task.identifier, task.agentId));
},
showInlineToggle: false,
});
}, [navigate]);
}, [canEditTask, navigate]);
const handleHideColumn = useCallback(
(columnKey: string) => {
@@ -213,7 +221,7 @@ const KanbanBoard = memo(() => {
return (
<DndContext
collisionDetection={pointerWithin}
sensors={sensors}
sensors={canEditTask ? sensors : []}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
@@ -224,7 +232,7 @@ const KanbanBoard = memo(() => {
return (
<KanbanColumn
columnKey={col.key}
droppable={col.droppable}
droppable={canEditTask && col.droppable}
key={col.key}
tasks={(group?.tasks ?? []) as TaskListItem[]}
total={group?.total ?? 0}
@@ -14,6 +14,7 @@ import {
getAttachmentFileIdsFromEditor,
pickAndInsertAttachments,
} from '@/features/EditorCanvas/editorAttachments';
import { usePermission } from '@/hooks/usePermission';
import { useGlobalStore } from '@/store/global';
import { useTaskStore } from '@/store/task';
@@ -36,6 +37,7 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
({ agentId, onCreated, showInlineToggle = true }) => {
const { t } = useTranslation('chat');
const { close } = useModalContext();
const { allowed: canCreateTask, reason } = usePermission('create_content');
const createTask = useTaskStore((s) => s.createTask);
const isCreating = useTaskStore((s) => s.isCreatingTask);
@@ -56,15 +58,17 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
}, [close, updateSystemStatus]);
const handleContentChange = useCallback(() => {
if (!canCreateTask) return;
if (!editor) return;
instructionRef.current = String(editor.getDocument('markdown') ?? '');
}, [editor]);
}, [canCreateTask, editor]);
const handleAttach = useCallback(() => {
pickAndInsertAttachments(editor);
}, [editor]);
const handleSubmit = useCallback(async () => {
if (!canCreateTask) return;
const instruction = instructionRef.current.trim();
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
if (!instruction && !title.trim() && !hasFiles) return;
@@ -86,7 +90,7 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
identifier: result.identifier,
});
}
}, [assigneeAgentId, close, createTask, editor, onCreated, priority, title]);
}, [assigneeAgentId, canCreateTask, close, createTask, editor, onCreated, priority, title]);
const handleSubmitRef = useRef(handleSubmit);
useEffect(() => {
@@ -106,7 +110,8 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
<Flexbox horizontal style={{ padding: '16px 24px 0' }}>
<Flexbox flex={1} style={{ minHeight: 180 }}>
<input
autoFocus
autoFocus={canCreateTask}
disabled={!canCreateTask}
placeholder={t('createTask.titlePlaceholder')}
value={title}
style={{
@@ -124,6 +129,7 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
onChange={(e) => setTitle(e.target.value)}
/>
<EditorCanvas
disabled={!canCreateTask}
editor={editor}
floatingToolbar={false}
placeholder={t('createTask.instructionPlaceholder')}
@@ -205,10 +211,11 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
</Flexbox>
<Button
disabled={isCreating}
disabled={!canCreateTask || isCreating}
loading={isCreating}
shape={'round'}
size={'small'}
title={canCreateTask ? undefined : reason}
type={'primary'}
onClick={handleSubmit}
>
@@ -1,8 +1,8 @@
import { Block, Flexbox } from '@lobehub/ui';
import { Divider } from 'antd';
import { Fragment, memo, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { useAgentStore } from '@/store/agent';
import { useTaskStore } from '@/store/task';
import { taskListSelectors } from '@/store/task/selectors';
@@ -13,7 +13,7 @@ import TaskListHeader from './TaskListHeader';
const AgentTaskCardList = memo(() => {
const agentId = useAgentStore((s) => s.activeAgentId);
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const useFetchTaskList = useTaskStore((s) => s.useFetchTaskList);
useFetchTaskList({ agentId });
@@ -2,8 +2,8 @@ import type { TaskStatus } from '@lobechat/types';
import { Block, ContextMenuTrigger, Flexbox, Text } from '@lobehub/ui';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { useTaskStore } from '@/store/task';
import type { TaskListItem } from '@/store/task/slices/list/initialState';
@@ -47,7 +47,7 @@ const AgentTaskItem = memo<TaskItemProps>(({ task, variant = 'default' }) => {
const taskDetail = useTaskStore((s) => s.taskDetailMap[task.identifier]);
const { items: contextMenuItems, onContextMenu: handleContextMenuOpen } =
useTaskItemContextMenu(task);
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const time = formatTaskItemDate(task.updatedAt || task.createdAt, {
formatOtherYear: t('time.formatOtherYear'),
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
import AgentItem from '@/features/PageEditor/Copilot/AgentSelector/AgentItem';
import { useFetchAgentList } from '@/hooks/useFetchAgentList';
import { usePermission } from '@/hooks/usePermission';
import { useAgentStore } from '@/store/agent';
import { agentSelectors, builtinAgentSelectors } from '@/store/agent/selectors';
import { useHomeStore } from '@/store/home';
@@ -53,6 +54,7 @@ const triggerStyle: CSSProperties = {
const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
({ children, currentAgentId, disabled, onChange, taskIdentifier }) => {
const { t } = useTranslation(['chat', 'common']);
const { allowed: canEditTask, reason } = usePermission('create_content');
const [key, setKey] = useState(0);
const [search, setSearch] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
@@ -108,6 +110,7 @@ const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
const handleAgentChange = useCallback(
(agentId: string) => {
if (!canEditTask) return;
if (agentId === currentAgentId) return;
setKey((k) => k + 1);
setSearch('');
@@ -119,7 +122,7 @@ const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
void updateTask(taskIdentifier, { assigneeAgentId: agentId });
}
},
[currentAgentId, onChange, taskIdentifier, updateTask],
[canEditTask, currentAgentId, onChange, taskIdentifier, updateTask],
);
const handleSearchKeyDown = useCallback(
@@ -148,8 +151,9 @@ const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
active?.scrollIntoView({ block: 'nearest' });
}, [activeIndex]);
const trigger = disabled ? (
<Tooltip title={t('taskDetail.reassignDisabled', { ns: 'chat' })}>
const blocked = disabled || !canEditTask;
const trigger = blocked ? (
<Tooltip title={disabled ? t('taskDetail.reassignDisabled', { ns: 'chat' }) : reason}>
<div
style={{ ...triggerStyle, cursor: 'not-allowed', opacity: 0.5 }}
onClick={(e) => e.stopPropagation()}
@@ -165,7 +169,7 @@ const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
return (
<Popover
disabled={disabled}
disabled={blocked}
key={key}
placement="bottomLeft"
styles={{ content: { padding: 0, width: 260 } }}
@@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePermission } from '@/hooks/usePermission';
import { useTaskStore } from '@/store/task';
import PriorityHighIcon from './icons/PriorityHighIcon';
@@ -53,6 +54,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
align-items: center;
color: ${cssVar.orange};
`,
triggerDisabled: css`
cursor: not-allowed;
opacity: 0.5;
&:hover {
color: ${cssVar.colorTextDescription};
filter: none;
}
`,
}));
interface TaskPriorityTagProps {
@@ -69,6 +79,7 @@ const TaskPriorityTag = memo<TaskPriorityTagProps>(
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const { t } = useTranslation('chat');
const { allowed: canEditTask, reason } = usePermission('create_content');
const updateTask = useTaskStore((s) => s.updateTask);
const refreshTaskList = useTaskStore((s) => s.refreshTaskList);
@@ -77,6 +88,7 @@ const TaskPriorityTag = memo<TaskPriorityTagProps>(
const handlePriorityChange = useCallback(
async (nextPriority: number) => {
if (!canEditTask) return;
if (nextPriority === currentLevel) return;
if (onChange) {
onChange(nextPriority);
@@ -88,7 +100,7 @@ const TaskPriorityTag = memo<TaskPriorityTagProps>(
await refreshTaskList();
setLoading(false);
},
[currentLevel, onChange, refreshTaskList, taskIdentifier, updateTask],
[canEditTask, currentLevel, onChange, refreshTaskList, taskIdentifier, updateTask],
);
const handlePriorityChangeRef = useRef(handlePriorityChange);
@@ -156,6 +168,19 @@ const TaskPriorityTag = memo<TaskPriorityTagProps>(
if (disableDropdown) return <>{triggerNode}</>;
if (!canEditTask)
return (
<Tooltip title={reason}>
<span
className={styles.triggerDisabled}
style={{ display: 'inline-flex' }}
onClick={(e) => e.stopPropagation()}
>
{triggerNode}
</span>
</Tooltip>
);
return (
<DropdownMenu items={menuItems} open={open} onOpenChange={setOpen}>
{triggerNode}
@@ -16,6 +16,7 @@ import type { ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePermission } from '@/hooks/usePermission';
import { useTaskStore } from '@/store/task';
import { renderMenuExtra } from './menuExtra';
@@ -90,6 +91,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
filter: brightness(0.85);
}
`,
triggerDisabled: css`
cursor: not-allowed;
display: inline-flex;
opacity: 0.5;
&:hover {
filter: none;
}
`,
}));
interface TaskStatusTagProps {
@@ -106,6 +116,7 @@ const TaskStatusTag = memo<TaskStatusTagProps>(
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const { t } = useTranslation('chat');
const { allowed: canEditTask, reason } = usePermission('create_content');
const updateTaskStatus = useTaskStore((s) => s.updateTaskStatus);
const displayStatus = status ?? 'backlog';
@@ -113,6 +124,7 @@ const TaskStatusTag = memo<TaskStatusTagProps>(
const handleStatusChange = useCallback(
async (nextStatus: TaskStatus) => {
if (!canEditTask) return;
if (nextStatus === displayStatus) return;
if (onChange) {
onChange(nextStatus);
@@ -127,7 +139,7 @@ const TaskStatusTag = memo<TaskStatusTagProps>(
setLoading(false);
}
},
[displayStatus, onChange, taskIdentifier, updateTaskStatus],
[canEditTask, displayStatus, onChange, taskIdentifier, updateTaskStatus],
);
const handleStatusChangeRef = useRef(handleStatusChange);
@@ -182,6 +194,15 @@ const TaskStatusTag = memo<TaskStatusTagProps>(
if (disableDropdown) return <>{triggerNode}</>;
if (!canEditTask)
return (
<Tooltip title={reason}>
<span className={styles.triggerDisabled} onClick={(e) => e.stopPropagation()}>
{triggerNode}
</span>
</Tooltip>
);
return (
<DropdownMenu items={menuItems} open={open} onOpenChange={setOpen}>
{triggerNode}
@@ -0,0 +1,111 @@
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTaskItemContextMenu } from './useTaskItemContextMenu';
const mocks = vi.hoisted(() => ({
copyToClipboard: vi.fn(),
deleteTask: vi.fn(),
messageSuccess: vi.fn(),
modalConfirm: vi.fn(),
refreshTaskList: vi.fn(),
runTask: vi.fn(),
transferItems: [
{ key: 'transfer-task', label: 'Transfer to...' },
{ key: 'copy-task', label: 'Copy to...' },
],
updateTask: vi.fn(),
updateTaskStatus: vi.fn(),
}));
vi.mock('@lobehub/ui', () => ({
closeContextMenu: vi.fn(),
copyToClipboard: mocks.copyToClipboard,
Flexbox: ({ children }: { children?: ReactNode }) => React.createElement('div', {}, children),
Icon: ({ icon: Icon }: { icon?: React.ComponentType }) =>
Icon ? React.createElement(Icon) : React.createElement('span'),
}));
vi.mock('antd', () => ({
App: {
useApp: () => ({
message: { success: mocks.messageSuccess },
modal: { confirm: mocks.modalConfirm },
}),
},
}));
vi.mock('@/business/client/hooks/useTaskTransferMenuItem', () => ({
useTaskTransferMenuItem: () => mocks.transferItems,
}));
vi.mock('@/hooks/useAppOrigin', () => ({
useAppOrigin: () => 'https://example.com',
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({ allowed: true }),
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (state: { inboxAgentId: string }) => unknown) =>
selector({ inboxAgentId: 'inbox-agent' }),
}));
vi.mock('@/store/agent/selectors', () => ({
builtinAgentSelectors: {
inboxAgentId: (state: { inboxAgentId: string }) => state.inboxAgentId,
},
}));
vi.mock('@/store/task', () => ({
useTaskStore: (
selector: (state: {
deleteTask: typeof mocks.deleteTask;
refreshTaskList: typeof mocks.refreshTaskList;
runTask: typeof mocks.runTask;
updateTask: typeof mocks.updateTask;
updateTaskStatus: typeof mocks.updateTaskStatus;
}) => unknown,
) =>
selector({
deleteTask: mocks.deleteTask,
refreshTaskList: mocks.refreshTaskList,
runTask: mocks.runTask,
updateTask: mocks.updateTask,
updateTaskStatus: mocks.updateTaskStatus,
}),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { defaultValue?: ReactNode; ns?: string }) =>
options?.defaultValue ?? key,
}),
}));
describe('useTaskItemContextMenu', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('does not render adjacent dividers around transfer actions', () => {
const { result } = renderHook(() =>
useTaskItemContextMenu({
identifier: 'T-1',
priority: 0,
status: 'backlog',
}),
);
const itemTypes = result.current.items.map((item) =>
item && typeof item === 'object' && 'type' in item ? item.type : 'item',
);
expect(
itemTypes.some((type, index) => type === 'divider' && itemTypes[index + 1] === 'divider'),
).toBe(false);
});
});
@@ -21,7 +21,9 @@ import {
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useTaskTransferMenuItem } from '@/business/client/hooks/useTaskTransferMenuItem';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import { usePermission } from '@/hooks/usePermission';
import { useAgentStore } from '@/store/agent';
import { builtinAgentSelectors } from '@/store/agent/selectors';
import { useTaskStore } from '@/store/task';
@@ -58,6 +60,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
const { t } = useTranslation(['chat', 'common']);
const { message } = App.useApp();
const appOrigin = useAppOrigin();
const { allowed: canEditTask } = usePermission('create_content');
const updateTaskStatus = useTaskStore((s) => s.updateTaskStatus);
const updateTask = useTaskStore((s) => s.updateTask);
@@ -73,6 +76,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
return useMemo<TaskContextMenuActions>(() => {
const triggerDelete = (identifier: string) => {
if (!canEditTask) return;
confirmModal({
content: t('taskDetail.deleteConfirm.content'),
okButtonProps: { danger: true },
@@ -96,8 +100,10 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
icon: <Icon color={meta.color} icon={meta.icon} />,
key: `status-${status}`,
label: t(`taskDetail.status.${status}`, { defaultValue: meta.label }),
disabled: !canEditTask,
onClick: ({ domEvent }: MenuInfo) => {
domEvent.stopPropagation();
if (!canEditTask) return;
if (status === currentStatus) return;
void updateTaskStatus(task.identifier, status);
},
@@ -116,8 +122,10 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
),
key: `priority-${level}`,
label: t(`taskDetail.${meta.labelKey}` as never, { defaultValue: meta.label }),
disabled: !canEditTask,
onClick: async ({ domEvent }: MenuInfo) => {
domEvent.stopPropagation();
if (!canEditTask) return;
if (level === currentPriority) return;
await updateTask(task.identifier, { priority: level });
await refreshTaskList();
@@ -138,8 +146,10 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
icon: <Icon icon={PlayIcon} />,
key: 'runNow',
label: t('taskList.contextMenu.runNow'),
disabled: !canEditTask,
onClick: async ({ domEvent }: MenuInfo) => {
domEvent.stopPropagation();
if (!canEditTask) return;
if (!task.assigneeAgentId && inboxAgentId) {
await updateTask(task.identifier, { assigneeAgentId: inboxAgentId });
}
@@ -151,6 +161,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
: []),
{
children: statusChildren,
disabled: !canEditTask,
icon: <Icon icon={CircleDashedIcon} />,
key: 'status',
label: t('taskList.contextMenu.status'),
@@ -160,6 +171,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
},
{
children: priorityChildren,
disabled: !canEditTask,
icon: <Icon icon={BarChart3Icon} />,
key: 'priority',
label: t('taskList.contextMenu.priority'),
@@ -191,11 +203,13 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
{ type: 'divider' },
{
danger: true,
disabled: !canEditTask,
icon: <Icon icon={Trash2Icon} />,
key: 'delete',
label: t('delete', { ns: 'common' }),
onClick: ({ domEvent }: MenuInfo) => {
domEvent.stopPropagation();
if (!canEditTask) return;
triggerDelete(task.identifier);
},
},
@@ -203,6 +217,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
};
const installKeyboardHandlers = (task: TaskContextMenuTarget) => {
if (!canEditTask) return;
cleanupRef.current?.();
activeSubmenuRef.current = null;
@@ -276,6 +291,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
return { buildItems, installKeyboardHandlers };
}, [
canEditTask,
message,
t,
appOrigin,
@@ -290,7 +306,39 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
export const useTaskItemContextMenu = (task: TaskContextMenuTarget): TaskItemContextMenu => {
const { buildItems, installKeyboardHandlers } = useTaskContextMenuActions();
const items = useMemo(() => buildItems(task), [buildItems, task]);
const transferItems = useTaskTransferMenuItem(task.identifier) as ContextMenuItem[] | null;
const items = useMemo(() => {
const base = buildItems(task);
if (!transferItems || transferItems.length === 0) return base;
// Insert transfer/copy entries above the final divider + delete pair so
// they sit next to the other lifecycle actions but kept distinct from
// in-place state changes.
const deleteAnchor = base.findIndex(
(item) =>
item !== null &&
typeof item === 'object' &&
'key' in item &&
(item as { key?: string }).key === 'delete',
);
if (deleteAnchor === -1) return [...base, ...transferItems];
const insertAt =
deleteAnchor > 0 &&
base[deleteAnchor - 1] !== null &&
typeof base[deleteAnchor - 1] === 'object' &&
'type' in (base[deleteAnchor - 1] as object) &&
(base[deleteAnchor - 1] as { type?: string }).type === 'divider'
? deleteAnchor - 1
: deleteAnchor;
return [
...base.slice(0, insertAt),
...transferItems,
{ type: 'divider' } as ContextMenuItem,
...base.slice(deleteAnchor),
];
}, [buildItems, task, transferItems]);
const onContextMenu = useCallback(
() => installKeyboardHandlers(task),
[installKeyboardHandlers, task],
@@ -3,9 +3,9 @@ import { Breadcrumb as AntBreadcrumb } from 'antd';
import { ChevronRight } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow';
import WorkspaceLink from '@/features/Workspace/WorkspaceLink';
import { useTaskStore } from '@/store/task';
import { styles } from './style';
@@ -49,11 +49,11 @@ const Breadcrumb = memo<BreadcrumbProps>(({ taskId }) => {
const ancestorCrumbs = ancestors.map(({ identifier, agentId }) => ({
key: identifier,
title: (
<Link to={taskDetailPath(identifier, agentId ?? undefined)}>
<WorkspaceLink to={taskDetailPath(identifier, agentId ?? undefined)}>
<Text color={'inherit'} weight={500}>
{identifier}
</Text>
</Link>
</WorkspaceLink>
),
}));
@@ -100,7 +100,11 @@ const Breadcrumb = memo<BreadcrumbProps>(({ taskId }) => {
separator={<Icon icon={ChevronRight} />}
items={[
{
title: taskId ? <Link to={'/tasks'}>{allTasksLabel}</Link> : allTasksLabel,
title: taskId ? (
<WorkspaceLink to={'/tasks'}>{allTasksLabel}</WorkspaceLink>
) : (
allTasksLabel
),
},
...ancestorCrumbs,
...(currentTaskCrumb ? [currentTaskCrumb] : []),
+2 -2
View File
@@ -1,8 +1,8 @@
'use client';
import { memo, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { lambdaClient } from '@/libs/trpc/client';
import { useGlobalStore } from '@/store/global';
@@ -11,7 +11,7 @@ const ChangelogModal = memo<{ currentId?: string }>(({ currentId: propCurrentId
s.status.latestChangelogId,
s.updateSystemStatus,
]);
const navigate = useNavigate();
const navigate = useWorkspaceAwareNavigate();
const [currentId, setCurrentId] = useState(propCurrentId);
useEffect(() => {
@@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next';
import { useBusinessAgentModeSync } from '@/business/client/hooks/useBusinessAgentMode';
import { useAgentId } from '@/features/ChatInput/hooks/useAgentId';
import { useToggleAgentMode } from '@/features/ChatInput/hooks/useToggleAgentMode';
import { usePermission } from '@/hooks/usePermission';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
@@ -65,6 +66,15 @@ const styles = createStaticStyles(({ css }) => ({
background: ${cssVar.colorFillSecondary};
}
`,
buttonDisabled: css`
cursor: not-allowed;
opacity: 0.5;
&:hover {
color: ${cssVar.colorTextSecondary};
background: ${cssVar.colorFillTertiary};
}
`,
option: css`
cursor: pointer;
@@ -111,6 +121,7 @@ const AgentMode = memo(() => {
const toggleAgentMode = useToggleAgentMode();
useBusinessAgentModeSync(agentId);
const [open, setOpen] = useState(false);
const { allowed: canCreateContent, reason } = usePermission('create_content');
const enableAgentMode = useAgentStore(agentByIdSelectors.getAgentEnableModeById(agentId));
@@ -119,10 +130,21 @@ const AgentMode = memo(() => {
const handleSelect = useCallback(
async (mode: 'chat' | 'agent') => {
if (!canCreateContent) return;
setOpen(false);
await toggleAgentMode(mode === 'agent');
},
[toggleAgentMode],
[canCreateContent, toggleAgentMode],
);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!canCreateContent) return;
setOpen(nextOpen);
},
[canCreateContent],
);
const agentTooltip = (
@@ -188,23 +210,30 @@ const AgentMode = memo(() => {
);
const button = (
<div className={styles.button}>
<div className={cx(styles.button, !canCreateContent && styles.buttonDisabled)}>
<Icon icon={CurrentIcon} size={14} />
<span>{t(`chatMode.${currentMode}`)}</span>
<Icon icon={ChevronDownIcon} size={12} />
</div>
);
if (!canCreateContent)
return (
<Tooltip title={reason}>
<div>{button}</div>
</Tooltip>
);
return (
<Popover
content={popoverContent}
open={open}
open={canCreateContent && open}
placement="bottomLeft"
trigger="click"
styles={{
content: { border: `1px solid ${cssVar.colorBorderSecondary}`, padding: 4 },
}}
onOpenChange={setOpen}
onOpenChange={handleOpenChange}
>
<div>
{open ? (
@@ -4,6 +4,7 @@ import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@/hooks/useIsMobile';
import { usePermission } from '@/hooks/usePermission';
import { useChatStore } from '@/store/chat';
import { useFileStore } from '@/store/file';
@@ -25,6 +26,7 @@ const Clear = memo(() => {
const clearCurrentMessages = useClearCurrentMessages();
const [confirmOpened, updateConfirmOpened] = useState(false);
const mobile = useIsMobile();
const { allowed: canCreate } = usePermission('create_content');
const actionTitle: any = confirmOpened ? void 0 : t('clearCurrentMessages', { ns: 'chat' });
@@ -33,7 +35,7 @@ const Clear = memo(() => {
return (
<Popconfirm
arrow={false}
okButtonProps={{ danger: true, type: 'primary' }}
okButtonProps={{ danger: true, disabled: !canCreate, type: 'primary' }}
open={confirmOpened}
placement={popconfirmPlacement}
title={
@@ -41,8 +43,14 @@ const Clear = memo(() => {
{t('confirmClearCurrentMessages', { ns: 'chat' })}
</div>
}
onConfirm={clearCurrentMessages}
onOpenChange={updateConfirmOpened}
onConfirm={() => {
if (!canCreate) return;
clearCurrentMessages();
}}
onOpenChange={(open) => {
if (!canCreate && open) return;
updateConfirmOpened(open);
}}
>
<Action
icon={Eraser}
@@ -9,6 +9,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import LevelSlider from '@/features/ModelSwitchPanel/components/ControlsForm/LevelSlider';
import { usePermission } from '@/hooks/usePermission';
import { useAgentStore } from '@/store/agent';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
@@ -63,6 +64,7 @@ const ToggleItem = memo<ToggleOption>(({ value, description, icon, label }) => {
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const isEnabled = useMemoryEnabled(agentId);
const { allowed: canCreate } = usePermission('create_content');
const isActive = value === 'on' ? isEnabled : !isEnabled;
@@ -72,7 +74,12 @@ const ToggleItem = memo<ToggleOption>(({ value, description, icon, label }) => {
align={'flex-start'}
className={cx(styles.option, isActive && styles.active)}
gap={12}
style={{
cursor: canCreate ? undefined : 'not-allowed',
opacity: canCreate ? undefined : 0.5,
}}
onClick={async () => {
if (!canCreate) return;
await updateAgentChatConfig({ memory: { enabled: value === 'on' } });
}}
>
@@ -92,6 +99,7 @@ const Controls = memo(() => {
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const isEnabled = useMemoryEnabled(agentId);
const { allowed: canCreate } = usePermission('create_content');
const effort = useAgentStore((s) => chatConfigByIdSelectors.getMemoryToolEffortById(agentId)(s));
const toggleOptions: ToggleOption[] = [
@@ -122,7 +130,13 @@ const Controls = memo(() => {
<div className={styles.title}>{t('memory.effort.title')}</div>
<div className={styles.description}>{t('memory.effort.desc')}</div>
</Flexbox>
<Flexbox flex={1}>
<Flexbox
flex={1}
style={{
opacity: canCreate ? undefined : 0.5,
pointerEvents: canCreate ? undefined : 'none',
}}
>
<LevelSlider<UserMemoryEffort>
defaultValue="medium"
levels={MEMORY_EFFORT_LEVELS}
@@ -133,6 +147,7 @@ const Controls = memo(() => {
2: t('memory.effort.high.title'),
}}
onChange={async (value) => {
if (!canCreate) return;
await updateAgentChatConfig({ memory: { effort: value, enabled: true } });
}}
/>
@@ -1,10 +1,11 @@
import { ModelIcon } from '@lobehub/icons';
import { Center } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Center, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useCallback } from 'react';
import { useBusinessModelModeConfig } from '@/business/client/hooks/useBusinessAgentMode';
import ModelSwitchPanel from '@/features/ModelSwitchPanel';
import { usePermission } from '@/hooks/usePermission';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
@@ -15,6 +16,20 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
icon: css`
transition: scale 400ms cubic-bezier(0.215, 0.61, 0.355, 1);
`,
modelDisabled: css`
cursor: not-allowed;
opacity: 0.5;
:hover {
background: transparent;
}
:active {
div {
scale: 1;
}
}
`,
model: css`
cursor: pointer;
border-radius: 24px;
@@ -35,6 +50,7 @@ const ModelSwitch = memo(() => {
const { actionSize, dropdownPlacement } = useActionBarContext();
const blockSize = actionSize?.blockSize ?? 32;
const iconSize = actionSize?.size ?? 20;
const { allowed: canCreateContent, reason } = usePermission('create_content');
const agentId = useAgentId();
const [model, provider, updateAgentConfigById] = useAgentStore((s) => [
@@ -46,11 +62,32 @@ const ModelSwitch = memo(() => {
const handleModelChange = useCallback(
async (params: { model: string; provider: string }) => {
if (!canCreateContent) return;
await updateAgentConfigById(agentId, applyBusinessModelModeConfig(params));
},
[agentId, applyBusinessModelModeConfig, updateAgentConfigById],
[agentId, applyBusinessModelModeConfig, canCreateContent, updateAgentConfigById],
);
const trigger = (
<Center
className={cx(styles.model, !canCreateContent && styles.modelDisabled)}
height={blockSize}
width={blockSize}
>
<div className={styles.icon}>
<ModelIcon model={model} size={iconSize} />
</div>
</Center>
);
if (!canCreateContent)
return (
<Tooltip title={reason}>
<div>{trigger}</div>
</Tooltip>
);
return (
<ModelSwitchPanel
model={model}
@@ -58,11 +95,7 @@ const ModelSwitch = memo(() => {
provider={provider}
onModelChange={handleModelChange}
>
<Center className={styles.model} height={blockSize} width={blockSize}>
<div className={styles.icon}>
<ModelIcon model={model} size={iconSize} />
</div>
</Center>
{trigger}
</ModelSwitchPanel>
);
});

Some files were not shown because too many files have changed in this diff Show More