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
14 changed files with 374 additions and 8 deletions
Showing only changes of commit 0ec0af405a - Show all commits

View File

@@ -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",

View File

@@ -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…": "Думаю…",

View File

@@ -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 ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined;

View File

@@ -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,
},
];

View File

@@ -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;
}
/**

View File

@@ -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<typeof formSchema>;
@@ -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({
)}
</Text>
<Switch
label={t("Start automatically")}
description={t(
"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.",
)}
checked={form.values.autoStart}
onChange={(event) =>
form.setFieldValue("autoStart", event.currentTarget.checked)
}
/>
<Textarea
label={t("Launch message")}
description={t(
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
)}
autosize
minRows={2}
maxRows={6}
{...form.getInputProps("launchMessage")}
/>
<Switch
label={t("Enabled")}
checked={form.values.enabled}

View File

@@ -25,6 +25,8 @@ describe('AiAgentRolesService guards', () => {
instructions: 'be a researcher',
modelConfig: null,
enabled: true,
autoStart: true,
launchMessage: null,
createdAt: new Date(),
updatedAt: new Date(),
...over,
@@ -159,6 +161,8 @@ describe('AiAgentRolesService guards', () => {
instructions: 'updated instructions',
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
enabled: false,
autoStart: true,
launchMessage: null,
createdAt,
updatedAt,
});
@@ -186,6 +190,35 @@ describe('AiAgentRolesService guards', () => {
expect(patch2.emoji).toBeUndefined();
expect(patch2.description).toBeUndefined();
});
it('autoStart/launchMessage thread through; launchMessage:"" clears to null', async () => {
const { service, repo } = makeService({ existing: makeRow() });
await service.update('ws-1', 'r1', {
autoStart: false,
launchMessage: ' custom ',
} as UpdateAgentRoleDto);
const patch = repo.update.mock.calls[0][2];
expect(patch.autoStart).toBe(false);
expect(patch.launchMessage).toBe('custom');
repo.update.mockClear();
// Explicit empty => clear to null.
await service.update('ws-1', 'r1', {
launchMessage: ' ',
} as UpdateAgentRoleDto);
expect(repo.update.mock.calls[0][2].launchMessage).toBeNull();
});
it('autoStart/launchMessage omitted => undefined (unchanged) in the patch', async () => {
const { service, repo } = makeService({ existing: makeRow() });
await service.update('ws-1', 'r1', {
name: 'Renamed',
} as UpdateAgentRoleDto);
const patch = repo.update.mock.calls[0][2];
expect(patch.autoStart).toBeUndefined();
expect(patch.launchMessage).toBeUndefined();
});
});
describe('remove', () => {
@@ -319,6 +352,40 @@ describe('AiAgentRolesService guards', () => {
} as CreateAgentRoleDto),
).rejects.toBe(other);
});
it('autoStart omitted => defaults to true; launchMessage omitted => null', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
} as CreateAgentRoleDto);
const values = repo.insert.mock.calls[0][0];
expect(values.autoStart).toBe(true);
expect(values.launchMessage).toBeNull();
});
it('autoStart:false + launchMessage round-trip (trimmed) to the repo', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
autoStart: false,
launchMessage: ' do the thing ',
} as CreateAgentRoleDto);
const values = repo.insert.mock.calls[0][0];
expect(values.autoStart).toBe(false);
expect(values.launchMessage).toBe('do the thing');
});
it('empty/whitespace launchMessage normalizes to null', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
launchMessage: ' ',
} as CreateAgentRoleDto);
expect(repo.insert.mock.calls[0][0].launchMessage).toBeNull();
});
});
describe('list view (security: non-admin must not see instructions/modelConfig)', () => {
@@ -349,19 +416,25 @@ describe('AiAgentRolesService guards', () => {
const list = await service.list('ws-1', false);
expect(list).toHaveLength(1);
const item = list[0] as unknown as Record<string, unknown>;
// The picker fields ARE present...
// The picker fields ARE present — INCLUDING the auto-start fields, which
// the client needs to decide whether/what to auto-send on role pick.
expect(item).toEqual({
id: 'r1',
name: 'Researcher',
emoji: '🔬',
description: 'finds things',
enabled: true,
autoStart: true,
launchMessage: null,
});
// ...and the admin-only fields are absent (not just undefined).
expect('instructions' in item).toBe(false);
expect('modelConfig' in item).toBe(false);
expect('createdAt' in item).toBe(false);
expect('updatedAt' in item).toBe(false);
// autoStart/launchMessage are deliberately NOT admin-only — present here.
expect('autoStart' in item).toBe(true);
expect('launchMessage' in item).toBe(true);
});
it('admin (isAdmin=true) gets the full view WITH instructions/modelConfig', async () => {

View File

@@ -22,6 +22,8 @@ export interface AgentRoleView {
instructions: string;
modelConfig: RoleModelConfig | null;
enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
createdAt: Date;
updatedAt: Date;
}
@@ -31,6 +33,11 @@ export interface AgentRoleView {
* role picker needs — deliberately WITHOUT `instructions`, `modelConfig`,
* creator or timestamps, so non-admins never receive the admin-authored prompt
* or the model override.
*
* `autoStart` / `launchMessage` ARE included (unlike instructions/modelConfig):
* the client needs them to decide whether and what to auto-send when a role card
* is picked. `launchMessage` is sent verbatim as a normal user message — it is
* not a secret, so exposing it to members is intentional.
*/
export interface AgentRolePickerView {
id: string;
@@ -38,6 +45,8 @@ export interface AgentRolePickerView {
emoji: string | null;
description: string | null;
enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
}
/**
@@ -87,6 +96,9 @@ export class AiAgentRolesService {
instructions,
modelConfig: modelConfig as Record<string, unknown> | null,
enabled: dto.enabled ?? true,
autoStart: dto.autoStart ?? true,
// Empty/whitespace-only => null (client default launch message).
launchMessage: emptyToNull(dto.launchMessage),
});
return this.toView(row);
} catch (err) {
@@ -128,6 +140,12 @@ export class AiAgentRolesService {
| Record<string, unknown>
| null),
enabled: dto.enabled,
autoStart: dto.autoStart,
// undefined => unchanged; '' => clear to null.
launchMessage:
dto.launchMessage === undefined
? undefined
: emptyToNull(dto.launchMessage),
});
} catch (err) {
throw rethrowDuplicateName(err, dto.name?.trim() || existing.name);
@@ -156,12 +174,18 @@ export class AiAgentRolesService {
instructions: row.instructions,
modelConfig: (row.modelConfig ?? null) as RoleModelConfig | null,
enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/** Non-admin picker view: id/name/emoji/description/enabled only. */
/**
* Non-admin picker view: id/name/emoji/description/enabled plus the auto-start
* fields the client needs to decide whether/what to send on role pick. Still
* WITHOUT instructions/modelConfig (admin-only).
*/
private toPickerView(row: AiAgentRole): AgentRolePickerView {
return {
id: row.id,
@@ -169,6 +193,8 @@ export class AiAgentRolesService {
emoji: row.emoji ?? null,
description: row.description ?? null,
enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
};
}
}

View File

@@ -78,4 +78,32 @@ describe('CreateAgentRoleDto with nested modelConfig', () => {
});
expect(errors.length).toBeGreaterThan(0);
});
it('accepts autoStart:false + a launchMessage', () => {
expect(
validateCreate({ ...base, autoStart: false, launchMessage: 'Go' }),
).toHaveLength(0);
});
it('rejects a non-boolean autoStart', () => {
const errors = validateCreate({ ...base, autoStart: 'yes' });
expect(errors.some((e) => e.property === 'autoStart')).toBe(true);
});
it('rejects a launchMessage longer than 2000 chars', () => {
const errors = validateCreate({
...base,
launchMessage: 'a'.repeat(2001),
});
expect(errors.some((e) => e.property === 'launchMessage')).toBe(true);
});
it('trims surrounding whitespace from launchMessage', () => {
const dto = plainToInstance(CreateAgentRoleDto, {
...base,
launchMessage: ' Look here ',
});
expect(validateSync(dto as object)).toHaveLength(0);
expect(dto.launchMessage).toBe('Look here');
});
});

View File

@@ -65,6 +65,22 @@ export class CreateAgentRoleDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
// Whether picking this role auto-sends a launch message and starts the chat.
// Omitted => default true (preserves the previous always-auto-start behavior).
@IsOptional()
@IsBoolean()
autoStart?: boolean;
// Optional custom auto-start text. Trimmed at the boundary (like chatModel);
// empty/whitespace-only => the client falls back to its default launch message.
@IsOptional()
@Transform(({ value }: TransformFnParams) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MaxLength(2000)
launchMessage?: string;
}
/** Admin update payload for an agent role (all fields optional). */
@@ -98,4 +114,19 @@ export class UpdateAgentRoleDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
// Whether picking this role auto-sends a launch message and starts the chat.
@IsOptional()
@IsBoolean()
autoStart?: boolean;
// Optional custom auto-start text. Trimmed at the boundary (like chatModel);
// empty/whitespace-only => the client falls back to its default launch message.
@IsOptional()
@Transform(({ value }: TransformFnParams) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MaxLength(2000)
launchMessage?: string;
}

View File

@@ -0,0 +1,29 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Per-role control over the new-chat auto-start behavior. Previously picking a
// role card ALWAYS sent a hardcoded launch message and started the dialog.
// These two columns make that configurable per role.
await db.schema
.alterTable('ai_agent_roles')
// When true (default), picking the role auto-sends a launch message and
// starts the conversation; when false the client only binds the role and
// reveals the composer (nothing is sent). Default true => existing roles
// keep their previous behavior.
.addColumn('auto_start', 'boolean', (col) => col.notNull().defaultTo(true))
// Optional custom text sent on auto-start instead of the built-in default.
// NULL/empty => the client falls back to its default launch message.
.addColumn('launch_message', 'text', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('ai_agent_roles')
.dropColumn('launch_message')
.execute();
await db.schema
.alterTable('ai_agent_roles')
.dropColumn('auto_start')
.execute();
}

View File

@@ -49,3 +49,81 @@ describe('AiAgentRoleRepo.findLiveEnabled', () => {
expect(await repo.findLiveEnabled('r-1', 'ws-1')).toBeUndefined();
});
});
/**
* Column-threading tests for the auto-start feature: insert defaults autoStart to
* true and stores an empty launchMessage as null; update only sets a column when
* the patch field is present, and clears launchMessage to null on empty string.
*/
describe('AiAgentRoleRepo insert/update auto-start columns', () => {
function makeInsertRepo() {
const values = jest.fn();
const builder = {
values: jest.fn((v: unknown) => {
values(v);
return builder;
}),
returningAll: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue({}),
};
const db = {
insertInto: jest.fn(() => builder),
} as unknown as KyselyDB;
return { repo: new AiAgentRoleRepo(db), values };
}
function makeUpdateRepo() {
const set = jest.fn();
const builder = {
set: jest.fn((s: unknown) => {
set(s);
return builder;
}),
where: jest.fn(() => builder),
execute: jest.fn().mockResolvedValue(undefined),
};
const db = {
updateTable: jest.fn(() => builder),
} as unknown as KyselyDB;
return { repo: new AiAgentRoleRepo(db), set };
}
it('insert defaults autoStart to true and stores empty launchMessage as null', async () => {
const { repo, values } = makeInsertRepo();
await repo.insert({
workspaceId: 'ws-1',
name: 'R',
instructions: 'do',
launchMessage: '',
});
const v = values.mock.calls[0][0];
expect(v.autoStart).toBe(true);
expect(v.launchMessage).toBeNull();
});
it('insert threads autoStart:false and a launchMessage', async () => {
const { repo, values } = makeInsertRepo();
await repo.insert({
workspaceId: 'ws-1',
name: 'R',
instructions: 'do',
autoStart: false,
launchMessage: 'Go',
});
const v = values.mock.calls[0][0];
expect(v.autoStart).toBe(false);
expect(v.launchMessage).toBe('Go');
});
it('update omits unchanged columns; clears launchMessage to null on empty', async () => {
const { repo, set } = makeUpdateRepo();
await repo.update('r-1', 'ws-1', { autoStart: false });
expect(set.mock.calls[0][0].autoStart).toBe(false);
expect('launchMessage' in set.mock.calls[0][0]).toBe(false);
const { repo: repo2, set: set2 } = makeUpdateRepo();
await repo2.update('r-1', 'ws-1', { launchMessage: '' });
expect(set2.mock.calls[0][0].launchMessage).toBeNull();
expect('autoStart' in set2.mock.calls[0][0]).toBe(false);
});
});

View File

@@ -76,6 +76,9 @@ export class AiAgentRoleRepo {
instructions: string;
modelConfig?: ModelConfigValue;
enabled?: boolean;
autoStart?: boolean;
// null/'' => stored as null (client default launch message).
launchMessage?: string | null;
},
trx?: KyselyTransaction,
): Promise<AiAgentRole> {
@@ -91,6 +94,9 @@ export class AiAgentRoleRepo {
instructions: values.instructions,
modelConfig: jsonbObject(values.modelConfig),
enabled: values.enabled ?? true,
autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null.
launchMessage: values.launchMessage ? values.launchMessage : null,
})
.returningAll()
.executeTakeFirst();
@@ -108,6 +114,9 @@ export class AiAgentRoleRepo {
// undefined => unchanged; null => clear; object => set.
modelConfig?: ModelConfigValue;
enabled?: boolean;
autoStart?: boolean;
// undefined => unchanged; null/'' => clear to null; string => set.
launchMessage?: string | null;
},
trx?: KyselyTransaction,
): Promise<void> {
@@ -121,6 +130,11 @@ export class AiAgentRoleRepo {
set.modelConfig = jsonbObject(patch.modelConfig);
}
if (patch.enabled !== undefined) set.enabled = patch.enabled;
if (patch.autoStart !== undefined) set.autoStart = patch.autoStart;
if (patch.launchMessage !== undefined) {
// Empty string clears to null (client default launch message).
set.launchMessage = patch.launchMessage ? patch.launchMessage : null;
}
await db
.updateTable('aiAgentRoles')
.set(set)

View File

@@ -601,6 +601,11 @@ export interface AiAgentRoles {
// { chatModel } | { driver, chatModel } | null. null => workspace default.
modelConfig: Json | null;
enabled: Generated<boolean>;
// When true (default), picking the role auto-sends a launch message and starts
// the new chat; when false the client only binds the role and shows the composer.
autoStart: Generated<boolean>;
// Optional custom auto-start text. null/empty => client default launch message.
launchMessage: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;