feat(ai-chat): per-role autoStart toggle + custom launchMessage (#149) #156
@@ -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 =
|
||||||
|
|||||||
72
apps/client/src/features/ai-chat/utils/role-launch.test.ts
Normal file
72
apps/client/src/features/ai-chat/utils/role-launch.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/client/src/features/ai-chat/utils/role-launch.ts
Normal file
34
apps/client/src/features/ai-chat/utils/role-launch.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user