diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index a4dd886b..1b3f54ab 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1266,6 +1266,10 @@ "Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.", "e.g. gpt-4o-mini": "e.g. gpt-4o-mini", "If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.", + "Start automatically": "Start automatically", + "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.", + "Launch message": "Launch message", + "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.", "Agent roles": "Agent roles", "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.", "No roles configured": "No roles configured", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index ca14b406..875e5493 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -677,6 +677,10 @@ "Ask AI": "Спросить ИИ", "AI agent": "AI-агент", "Take a look at the current document": "Посмотри текущий документ", + "Start automatically": "Запускать автоматически", + "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.", + "Launch message": "Стартовое сообщение", + "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.", "AI agent is typing…": "AI-агент печатает…", "{{name}} is typing…": "{{name}} печатает…", "Thinking…": "Думаю…", diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index b5dc6d48..3d84cf3f 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -315,16 +315,44 @@ export default function ChatThread({ // of a generic "Something went wrong". const errorView = error ? describeChatError(error.message ?? "", t) : null; - // Clicking a role card both binds the role to THIS new chat and immediately - // starts the conversation. roleIdRef is set synchronously here because the - // parent's selectedRoleId state update would only reach roleIdRef on the next - // render — after this synchronous sendMessage has already read it. + // A role was picked with autoStart=false: the role is bound but NOTHING was + // sent, so chatId stays null and the empty state would keep showing the cards. + // This flag hides the cards and reveals the composer (with the role indicated) + // so the user can type the first message themselves. roleIdRef is already set, + // so that first manual message carries the roleId. + const [rolePickedNoSend, setRolePickedNoSend] = useState(false); + + // Clicking a role card always binds the role to THIS new chat. Whether it also + // auto-starts the conversation is per-role (autoStart). roleIdRef is set + // synchronously here because the parent's selectedRoleId state update would + // only reach roleIdRef on the next render — after this synchronous sendMessage + // has already read it. const handleRolePick = (role: IAiRole): void => { roleIdRef.current = role.id; onRolePicked?.(role); - sendMessage({ text: t("Take a look at the current document") }); + if (role.autoStart) { + // Custom launch message when set; otherwise the built-in default text. + sendMessage({ + text: role.launchMessage?.trim() || t("Take a look at the current document"), + }); + } else { + // Bind only: hide the cards and show the composer, send nothing. + setRolePickedNoSend(true); + } }; - const showRoleCards = chatId === null && (roles?.length ?? 0) > 0; + // Reset the "picked, not sent" flag when the thread returns to a truly empty, + // role-less state — e.g. the user hit "New chat" after picking an autoStart=false + // role. That path clears the parent's selectedRoleId (roleId -> null) but leaves + // chatId null, so the thread never remounts and the flag would stay set, hiding + // the cards forever. A picked-and-bound role keeps roleId non-null, so the cards + // correctly stay hidden then. Render-phase reset (React "adjust state on prop + // change"): one-shot — it re-renders with the flag false and the guard no longer + // matches, so it cannot loop. (Review of #149.) + if (chatId === null && roleId == null && rolePickedNoSend) { + setRolePickedNoSend(false); + } + const showRoleCards = + chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend; const roleCardsEmptyState = showRoleCards ? ( ) : undefined; diff --git a/apps/client/src/features/ai-chat/components/role-cards.test.tsx b/apps/client/src/features/ai-chat/components/role-cards.test.tsx index d8213d8d..af3f4dd2 100644 --- a/apps/client/src/features/ai-chat/components/role-cards.test.tsx +++ b/apps/client/src/features/ai-chat/components/role-cards.test.tsx @@ -13,6 +13,8 @@ const roles: IAiRole[] = [ emoji: "🏴‍☠️", description: "Talks like a pirate", enabled: true, + autoStart: true, + launchMessage: null, }, { id: "r2", @@ -20,6 +22,8 @@ const roles: IAiRole[] = [ emoji: null, description: null, enabled: true, + autoStart: true, + launchMessage: null, }, ]; diff --git a/apps/client/src/features/ai-chat/types/ai-chat.types.ts b/apps/client/src/features/ai-chat/types/ai-chat.types.ts index f4b0ccb6..db43f674 100644 --- a/apps/client/src/features/ai-chat/types/ai-chat.types.ts +++ b/apps/client/src/features/ai-chat/types/ai-chat.types.ts @@ -53,6 +53,10 @@ export interface IAiRole { instructions?: string; modelConfig?: IAiRoleModelConfig | null; enabled: boolean; + // Whether picking the role auto-sends a launch message and starts the chat. + autoStart: boolean; + // Custom auto-start text; null/empty => the default launch message is sent. + launchMessage: string | null; createdAt?: string; updatedAt?: string; } @@ -65,6 +69,8 @@ export interface IAiRoleCreate { instructions: string; modelConfig?: IAiRoleModelConfig | null; enabled?: boolean; + autoStart?: boolean; + launchMessage?: string; } /** Admin update payload for a role (partial). */ @@ -76,6 +82,8 @@ export interface IAiRoleUpdate { instructions?: string; modelConfig?: IAiRoleModelConfig | null; enabled?: boolean; + autoStart?: boolean; + launchMessage?: string; } /** diff --git a/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx b/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx index afbb4f59..86b29aec 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx @@ -53,6 +53,8 @@ const formSchema = z.object({ driver: z.enum(["", ...AI_DRIVER_VALUES]), chatModel: z.string(), enabled: z.boolean(), + autoStart: z.boolean(), + launchMessage: z.string(), }); type FormValues = z.infer; @@ -83,6 +85,8 @@ export default function AiAgentRoleForm({ driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"], chatModel: role?.modelConfig?.chatModel ?? "", enabled: role?.enabled ?? true, + autoStart: role?.autoStart ?? true, + launchMessage: role?.launchMessage ?? "", }, }); @@ -96,6 +100,8 @@ export default function AiAgentRoleForm({ driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"], chatModel: role?.modelConfig?.chatModel ?? "", enabled: role?.enabled ?? true, + autoStart: role?.autoStart ?? true, + launchMessage: role?.launchMessage ?? "", }); form.resetDirty(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -122,6 +128,8 @@ export default function AiAgentRoleForm({ instructions: values.instructions, modelConfig, enabled: values.enabled, + autoStart: values.autoStart, + launchMessage: values.launchMessage, }; await updateMutation.mutateAsync(payload); } else { @@ -132,6 +140,10 @@ export default function AiAgentRoleForm({ instructions: values.instructions, modelConfig, enabled: values.enabled, + autoStart: values.autoStart, + // Send the raw (trimmed) value like the update path; the server + // normalizes an empty string to null (emptyToNull). Symmetric. + launchMessage: values.launchMessage, }; await createMutation.mutateAsync(payload); } @@ -195,6 +207,28 @@ export default function AiAgentRoleForm({ )} + + form.setFieldValue("autoStart", event.currentTarget.checked) + } + /> + +