Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/channels/base/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,8 @@ interface ChannelConfigFieldDescriptorBase {
options?: ReadonlyArray<{ value: string; label: string }>;
default?: string;
description?: string;
/** Render string fields as a multi-line text area in management UIs. */
multiline?: boolean;
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
}

export interface ChannelConfigValueFieldDescriptor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function assertDescriptorWireShape(
'options',
'default',
'description',
'multiline',
]);
if (descriptor.kind === 'object') {
allowedKeys.add('properties');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,22 @@ describe('built-in channel registry', () => {
'groupPolicy',
'sessionScope',
'multiSession',
'instructions',
]);
expect(
entry?.fields.find((field) => field.key === 'senderPolicy'),
).toMatchObject({ default: 'pairing' });
// The shared descriptor is injected into every manageable channel, and
// dingtalk substitutes its own default block instead of composing with it
// (DingtalkAdapter.ts:908), so pin both the multiline render hint and the
// neutral copy: neither survives an accidental revert to a plain string
// field or to text that promises additive guidance.
const instructions = entry?.fields.find(
(field) => field.key === 'instructions',
);
expect(instructions).toMatchObject({ kind: 'string', multiline: true });
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
expect(instructions?.description).toContain(
'replace their own default guidance',
);
});
});
18 changes: 18 additions & 0 deletions packages/cli/src/commands/channel/channel-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,24 @@ describe('channel registry', () => {
.filter((entry) => entry.manageable)
.map((entry) => entry.type),
).toEqual(['dingtalk', 'dws', 'wecom', 'feishu', 'github', 'gitlab']);
// The registry skips the shared `instructions` injection for any channel
// that declares its own, so pin the served descriptor for every manageable
// built-in: one field, the multiline render hint (without it the editor
// falls back to a single-line input that flattens stored guidance on the
// first edit) and the neutral copy that does not promise additive merge.
for (const entry of builtinCatalog.filter((item) => item.manageable)) {
const instructions = entry.fields.filter(
(field) => field.key === 'instructions',
);
expect(instructions).toHaveLength(1);
expect(instructions[0]).toMatchObject({
kind: 'string',
multiline: true,
});
expect(instructions[0].description).toContain(
'replace their own default guidance',
);
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
}
expect(
catalog.find((entry) => entry.type === 'dingtalk')?.fields,
).toContainEqual(
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/commands/channel/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ function managementFieldsWithSharedControls(
'Retain an owner-scoped catalog of named tasks in daemon-managed mode',
},
]),
...(declared.has('instructions')
? []
: [
{
key: 'instructions',
label: 'Instructions',
kind: 'string' as const,
multiline: true,
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
description:
'Guidance injected into each channel session context; some channels replace their own default guidance when this is set',
},
]),
];
}

Expand Down
2 changes: 2 additions & 0 deletions packages/sdk-typescript/src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3976,6 +3976,8 @@ interface DaemonChannelConfigFieldDescriptorBase {
options?: ReadonlyArray<{ value: string; label: string }>;
default?: string;
description?: string;
/** Render string fields as a multi-line text area in management UIs. */
multiline?: boolean;
}

export interface DaemonChannelConfigValueFieldDescriptor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,30 @@ const EXCLUSIVE_MINIMUM: DaemonChannelTypeDescriptor = {
],
};

// The shared `instructions` control the channel registry injects into every
// manageable channel (channel-registry.ts), plus a plain string sibling. The
// descriptor label for `instructions` intentionally differs from the i18n
// value so a missing i18n key surfaces the untranslated fallback instead of
// passing the assertions below.
const MULTILINE_INSTRUCTIONS: DaemonChannelTypeDescriptor = {
type: 'example',
displayName: 'Example',
manageable: true,
fields: [
{
key: 'instructions',
label: 'Session instructions (descriptor)',
kind: 'string',
multiline: true,
},
{
key: 'apiEndpoint',
label: 'API endpoint (descriptor)',
kind: 'string',
},
],
};

const INSTANCE: DaemonChannelInstanceSnapshot = {
name: 'release-bot',
config: {
Expand Down Expand Up @@ -278,6 +302,22 @@ function setInputValue(input: HTMLInputElement, value: string) {
input.dispatchEvent(new Event('input', { bubbles: true }));
}

function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)?.set?.call(textarea, value);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}

function sectionHeadingOf(element: HTMLElement | null): string | null {
const heading = element
?.closest('section')
?.querySelector('h3')
?.textContent?.trim();
return heading ?? null;
}

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
Expand Down Expand Up @@ -475,6 +515,104 @@ describe('ChannelEditorDialog', () => {
expect(document.body.textContent).toContain('By user');
});

it('renders a multiline string field as a textarea and leaves a plain one as an input', async () => {
await renderDialog({ descriptor: MULTILINE_INSTRUCTIONS });

const instructions = fieldByLabel('Instructions');
const endpoint = fieldByLabel('API endpoint (descriptor)');

expect(instructions?.tagName).toBe('TEXTAREA');
expect(endpoint?.tagName).toBe('INPUT');
});

it('groups the shared instructions control with conversation management', async () => {
await renderDialog({ descriptor: MULTILINE_INSTRUCTIONS });

// `instructions` is not a credential: it is stored in clear text and
// injected into the session context, so it belongs with the other shared
// session controls rather than in the catch-all credentials panel.
expect(sectionHeadingOf(fieldByLabel('Instructions'))).toBe(
'Conversation management',
);
expect(sectionHeadingOf(fieldByLabel('API endpoint (descriptor)'))).toBe(
'Credentials',
);
});

it('saves a multi-line instructions value with the newline intact', async () => {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
const onSave = vi.fn().mockResolvedValue(undefined);
await renderDialog({ descriptor: MULTILINE_INSTRUCTIONS, onSave });

const instructions = fieldByLabel('Instructions');
expect(instructions).toBeInstanceOf(HTMLTextAreaElement);

await act(async () => {
setInputValue(inputByLabel('Instance name')!, 'release-bot');
setTextareaValue(
instructions as HTMLTextAreaElement,
' line one\nline two ',
);
});

const save = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Save',
);
await act(async () => {
save?.click();
});

// assignField trims the outer whitespace but must not flatten the
// embedded newline, or the control cannot carry multi-line guidance.
expect(onSave).toHaveBeenCalledWith(
'release-bot',
expect.objectContaining({
config: expect.objectContaining({
instructions: 'line one\nline two',
}),
}),
);
});

it('loads a stored multi-line instructions value into the textarea and saves it unchanged', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
// Edit mode: createChannelEditorDraft loads a stored string untrimmed
// (channel-editor-state.ts:110) while assignField trims on save
// (channel-editor-state.ts:249), so the fixture carries no outer
// whitespace and the round trip must be exact. Without the draft value
// threaded into the Textarea, an operator editing a configured channel
// sees an empty box and their first keystroke replaces the whole block.
await renderDialog({
descriptor: MULTILINE_INSTRUCTIONS,
instance: {
...INSTANCE,
config: { ...INSTANCE.config, instructions: 'line one\nline two' },
},
onSave,
});

const instructions = fieldByLabel('Instructions');
expect(instructions).toBeInstanceOf(HTMLTextAreaElement);
expect((instructions as HTMLTextAreaElement).value).toBe(
'line one\nline two',
);

const save = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Save',
);
await act(async () => {
save?.click();
});

expect(onSave).toHaveBeenCalledWith(
'release-bot',
expect.objectContaining({
config: expect.objectContaining({
instructions: 'line one\nline two',
}),
}),
);
});

it('submits a new instance with typed fields and the current revision', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
await renderDialog({ onSave });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
DialogTitle,
} from '../ui/dialog';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Label } from '../ui/label';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import {
Expand Down Expand Up @@ -102,14 +103,19 @@ const SHARED_ACCESS_FIELD_KEYS = new Set([
'allowedUsers',
'groupPolicy',
]);
const SHARED_SESSION_FIELD_KEYS = new Set(['sessionScope', 'multiSession']);
const SHARED_SESSION_FIELD_KEYS = new Set([
'sessionScope',
'multiSession',
'instructions',
]);

const SHARED_FIELD_LABEL_KEYS: Record<string, string> = {
senderPolicy: 'channels.editor.field.shared.senderPolicy',
allowedUsers: 'channels.editor.field.shared.allowedUsers',
groupPolicy: 'channels.editor.field.shared.groupPolicy',
sessionScope: 'channels.editor.field.shared.sessionScope',
multiSession: 'channels.editor.field.shared.multiSession',
instructions: 'channels.editor.field.shared.instructions',
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
};

export interface ChannelEditorDialogProps {
Expand Down Expand Up @@ -592,6 +598,26 @@ export function ChannelEditorDialog({
</FieldShell>
);
}
if (field.kind === 'string' && field.multiline) {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
return (
<FieldShell
key={field.key}
id={id}
label={fieldLabel(field)}
required={field.required}
description={fieldDescription(field)}
error={error}
>
<Textarea
id={id}
value={String(value ?? '')}
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
aria-invalid={Boolean(error)}
aria-required={field.required}
onChange={(event) => update(event.target.value)}
/>
</FieldShell>
);
}
return (
<FieldShell
key={field.key}
Expand Down
6 changes: 6 additions & 0 deletions packages/web-shell/client/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3349,6 +3349,9 @@ const EN: Messages = {
'channels.editor.field.shared.sessionScope.detail.single':
'Every message shares one conversation; best for a single-bot duty channel.',
'channels.editor.field.shared.multiSession': 'Named tasks',
'channels.editor.field.shared.instructions': 'Instructions',
'channels.editor.field.shared.instructions.description':
'Guidance injected into the context of each channel session. Some channels replace their own default guidance when this is set.',
'channels.editor.field.shared.multiSession.description':
'Keep a separate owner-scoped catalog of named tasks in daemon-managed mode.',
'channels.editor.policy.pairing.title': 'Pairing',
Expand Down Expand Up @@ -6671,6 +6674,9 @@ const ZH: Messages = {
'channels.editor.field.shared.sessionScope.detail.single':
'所有消息共用一个对话,适合单一机器人值守场景。',
'channels.editor.field.shared.multiSession': '命名任务',
'channels.editor.field.shared.instructions': '指引',
'channels.editor.field.shared.instructions.description':
'注入到每个频道会话上下文中的指引。部分频道在设置后会用它替换自身的默认指引。',
'channels.editor.field.shared.multiSession.description':
'在 daemon 托管模式下,为每位用户保留相互隔离的命名任务目录。',
'channels.editor.policy.pairing.title': '配对模式',
Expand Down
Loading