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..8cd69484 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -21,6 +21,10 @@ import { IAiChatMessageRow, IAiRole, } from "@/features/ai-chat/types/ai-chat.types.ts"; +import { + roleLaunchMessage, + shouldResetRolePicked, +} from "@/features/ai-chat/utils/role-launch.ts"; import { describeChatError } from "@/features/ai-chat/utils/error-message.ts"; import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts"; import { @@ -315,16 +319,45 @@ 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") }); + const launch = roleLaunchMessage( + role, + t("Take a look at the current document"), + ); + if (launch !== null) { + sendMessage({ text: launch }); + } else { + // autoStart=false -> bind only: hide the cards, show the composer. + 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 (shouldResetRolePicked(chatId, roleId, 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/ai-chat/utils/role-launch.test.ts b/apps/client/src/features/ai-chat/utils/role-launch.test.ts new file mode 100644 index 00000000..22d36906 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/role-launch.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { roleLaunchMessage, shouldResetRolePicked } from "./role-launch.ts"; + +const DEFAULT = "Take a look at the current document"; + +// Covers the three-way handleRolePick behavior (issue #149) without mounting the +// chat-thread component — the logic lives in these pure helpers. +describe("roleLaunchMessage", () => { + it("autoStart=true + custom launchMessage -> the trimmed custom text", () => { + expect( + roleLaunchMessage( + { autoStart: true, launchMessage: " Draft a plan " }, + DEFAULT, + ), + ).toBe("Draft a plan"); + }); + + it("autoStart=true + empty launchMessage -> the default fallback", () => { + expect( + roleLaunchMessage({ autoStart: true, launchMessage: "" }, DEFAULT), + ).toBe(DEFAULT); + }); + + it("autoStart=true + whitespace-only launchMessage -> the default fallback", () => { + expect( + roleLaunchMessage({ autoStart: true, launchMessage: " " }, DEFAULT), + ).toBe(DEFAULT); + }); + + it("autoStart=true + null launchMessage -> the default fallback", () => { + expect( + roleLaunchMessage({ autoStart: true, launchMessage: null }, DEFAULT), + ).toBe(DEFAULT); + }); + + it("autoStart=false -> null (bind only, send nothing) regardless of message", () => { + expect( + roleLaunchMessage( + { autoStart: false, launchMessage: "ignored" }, + DEFAULT, + ), + ).toBeNull(); + expect( + roleLaunchMessage({ autoStart: false, launchMessage: null }, DEFAULT), + ).toBeNull(); + }); +}); + +// Regression guard for #149: the "picked, not sent" flag must reset when the +// user starts a fresh chat after an autoStart=false pick. On pre-fix code there +// was no reset, so the flag stayed stuck and the role cards never returned — +// this is exactly the `true` case below (which the old code never acted on). +describe("shouldResetRolePicked", () => { + it("resets when the thread is empty and the bound role was cleared (New chat)", () => { + // chatId still null, roleId cleared by the parent, flag stuck -> reset. + expect(shouldResetRolePicked(null, null, true)).toBe(true); + expect(shouldResetRolePicked(null, undefined, true)).toBe(true); + }); + + it("does NOT reset while a role is still bound (cards stay hidden, composer shown)", () => { + // Right after the autoStart=false pick, roleId is the picked role -> keep hidden. + expect(shouldResetRolePicked(null, "role-1", true)).toBe(false); + }); + + it("does NOT reset once the chat exists (a message was sent / chat created)", () => { + expect(shouldResetRolePicked("chat-1", null, true)).toBe(false); + }); + + it("is a no-op when the flag is already false", () => { + expect(shouldResetRolePicked(null, null, false)).toBe(false); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/role-launch.ts b/apps/client/src/features/ai-chat/utils/role-launch.ts new file mode 100644 index 00000000..48ffdfa3 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/role-launch.ts @@ -0,0 +1,34 @@ +import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts"; + +/** + * Decide what (if anything) to auto-send when an agent role card is picked + * (issue #149). Extracted as a pure function so the three-way behavior is + * unit-testable without mounting the chat-thread component: + * - autoStart=false -> null (bind the role only, send nothing) + * - autoStart=true + message -> the trimmed custom launchMessage + * - autoStart=true + empty/null -> the default fallback text + */ +export function roleLaunchMessage( + role: Pick, + defaultText: string, +): string | null { + if (!role.autoStart) return null; + return role.launchMessage?.trim() || defaultText; +} + +/** + * Whether the "role picked but nothing sent yet" flag (`rolePickedNoSend`) + * should reset to false. After an autoStart=false pick the thread shows the + * composer with chatId still null; when the user then starts a fresh chat the + * parent clears the bound role (roleId -> null) but chatId stays null, so the + * thread never remounts and the flag would otherwise stay set — hiding the role + * cards forever. Reset exactly in that state; a still-bound role (roleId set) + * keeps the cards hidden. (Regression guard for #149.) + */ +export function shouldResetRolePicked( + chatId: string | null, + roleId: string | null | undefined, + rolePickedNoSend: boolean, +): boolean { + return chatId === null && roleId == null && rolePickedNoSend; +} 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) + } + /> + +