feat(ai-chat): per-role autoStart toggle + custom launchMessage (#149) #156

Merged
vvzvlad merged 2 commits from feat/ai-role-autostart into develop 2026-06-24 12:43:43 +03:00
4 changed files with 120 additions and 9 deletions
Showing only changes of commit 5519f4b23b - Show all commits

View File

@@ -21,6 +21,10 @@ import {
IAiChatMessageRow, IAiChatMessageRow,
IAiRole, IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts"; } 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 { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts"; import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import { import {
@@ -330,13 +334,14 @@ export default function ChatThread({
const handleRolePick = (role: IAiRole): void => { const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id; roleIdRef.current = role.id;
onRolePicked?.(role); onRolePicked?.(role);
if (role.autoStart) { const launch = roleLaunchMessage(
// Custom launch message when set; otherwise the built-in default text. role,
sendMessage({ t("Take a look at the current document"),
text: role.launchMessage?.trim() || t("Take a look at the current document"), );
}); if (launch !== null) {
sendMessage({ text: launch });
} else { } else {
// Bind only: hide the cards and show the composer, send nothing. // autoStart=false -> bind only: hide the cards, show the composer.
setRolePickedNoSend(true); setRolePickedNoSend(true);
} }
}; };
@@ -348,7 +353,7 @@ export default function ChatThread({
// correctly stay hidden then. Render-phase reset (React "adjust state on prop // 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 // change"): one-shot — it re-renders with the flag false and the guard no longer
// matches, so it cannot loop. (Review of #149.) // matches, so it cannot loop. (Review of #149.)
if (chatId === null && roleId == null && rolePickedNoSend) { if (shouldResetRolePicked(chatId, roleId, rolePickedNoSend)) {
setRolePickedNoSend(false); setRolePickedNoSend(false);
} }
const showRoleCards = const showRoleCards =

View File

@@ -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);
});
});

View File

@@ -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<IAiRole, "autoStart" | "launchMessage">,
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;
}

View File

@@ -96,7 +96,7 @@ export class AiAgentRoleRepo {
enabled: values.enabled ?? true, enabled: values.enabled ?? true,
autoStart: values.autoStart ?? true, autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null. // Empty string is treated as "no custom text" => null.
launchMessage: values.launchMessage ? values.launchMessage : null, launchMessage: values.launchMessage || null,
}) })
.returningAll() .returningAll()
.executeTakeFirst(); .executeTakeFirst();
@@ -133,7 +133,7 @@ export class AiAgentRoleRepo {
if (patch.autoStart !== undefined) set.autoStart = patch.autoStart; if (patch.autoStart !== undefined) set.autoStart = patch.autoStart;
if (patch.launchMessage !== undefined) { if (patch.launchMessage !== undefined) {
// Empty string clears to null (client default launch message). // Empty string clears to null (client default launch message).
set.launchMessage = patch.launchMessage ? patch.launchMessage : null; set.launchMessage = patch.launchMessage || null;
} }
await db await db
.updateTable('aiAgentRoles') .updateTable('aiAgentRoles')