@chrysb/alphaclaw 0.6.2-beta.4 → 0.6.2-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ <svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g clip-path="url(#clip0_4127_70105)">
3
+ <path d="M11.379 33.9993C11.379 37.1358 8.84512 39.6507 5.7276 39.6507C2.61008 39.6507 0.0572205 37.1168 0.0572205 33.9993C0.0572205 30.8817 2.5911 28.3479 5.70862 28.3479H11.36V33.9993H11.379Z" fill="#E01E5A"/>
4
+ <path d="M14.1962 33.9997C14.1962 30.8632 16.7301 28.3483 19.8476 28.3483C22.9651 28.3483 25.499 30.8822 25.499 33.9997V48.1353C25.499 51.2718 22.9651 53.7867 19.8476 53.7867C16.7301 53.7867 14.1962 51.2718 14.1962 48.1353V33.9997Z" fill="#E01E5A"/>
5
+ <path d="M19.8662 11.2673C16.7296 11.2673 14.2148 8.73347 14.2148 5.61594C14.2148 2.49842 16.7486 -0.0354538 19.8662 -0.0354538C22.9837 -0.0354538 25.5175 2.49842 25.5175 5.61594V11.2673H19.8662Z" fill="#36C5F0"/>
6
+ <path d="M19.8682 14.1334C23.0047 14.1334 25.5196 16.6673 25.5196 19.7848C25.5196 22.9023 22.9857 25.4362 19.8682 25.4362H5.67566C2.53916 25.4362 0.0242615 22.9023 0.0242615 19.7848C0.0242615 16.6673 2.55814 14.1334 5.67566 14.1334H19.8682Z" fill="#36C5F0"/>
7
+ <path d="M42.5323 19.7853C42.5323 16.6488 45.0662 14.1339 48.1837 14.1339C51.3012 14.1339 53.8351 16.6678 53.8351 19.7853C53.8351 22.9028 51.3012 25.4367 48.1837 25.4367H42.5323V19.7853Z" fill="#2EB67D"/>
8
+ <path d="M39.7126 19.7934C39.7126 22.9299 37.1787 25.4448 34.0612 25.4448C30.9436 25.4448 28.4098 22.911 28.4098 19.7934V5.61986C28.4098 2.48336 30.9436 -0.0315399 34.0612 -0.0315399C37.1787 -0.0315399 39.7126 2.48336 39.7126 5.61986V19.7934Z" fill="#2EB67D"/>
9
+ <path d="M34.0376 42.482C37.1741 42.482 39.689 45.0158 39.689 48.1334C39.689 51.2509 37.1552 53.7848 34.0376 53.7848C30.9201 53.7848 28.3862 51.2509 28.3862 48.1334V42.482H34.0376Z" fill="#ECB22E"/>
10
+ <path d="M34.0381 39.6507C30.9016 39.6507 28.3867 37.1168 28.3867 33.9993C28.3867 30.8818 30.9206 28.3479 34.0381 28.3479H48.2306C51.3671 28.3479 53.882 30.8818 53.882 33.9993C53.882 37.1168 51.3482 39.6507 48.2306 39.6507H34.0381Z" fill="#ECB22E"/>
11
+ </g>
12
+ <defs>
13
+ <clipPath id="clip0_4127_70105">
14
+ <rect width="54" height="54" fill="white"/>
15
+ </clipPath>
16
+ </defs>
17
+ </svg>
@@ -0,0 +1,59 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { ActionButton } from "./action-button.js";
4
+ import { AddLineIcon } from "./icons.js";
5
+ import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
6
+
7
+ const html = htm.bind(h);
8
+
9
+ export const AddChannelMenu = ({
10
+ open = false,
11
+ onClose = () => {},
12
+ onToggle = () => {},
13
+ triggerDisabled = false,
14
+ channelIds = [],
15
+ getChannelMeta = () => ({ label: "Channel", iconSrc: "" }),
16
+ isChannelDisabled = () => false,
17
+ onSelectChannel = () => {},
18
+ }) => html`
19
+ <${OverflowMenu}
20
+ open=${open}
21
+ ariaLabel="Add channel"
22
+ title="Add channel"
23
+ onClose=${onClose}
24
+ onToggle=${onToggle}
25
+ renderTrigger=${({ onToggle: handleToggle, ariaLabel, title }) => html`
26
+ <${ActionButton}
27
+ onClick=${handleToggle}
28
+ disabled=${triggerDisabled}
29
+ loading=${false}
30
+ loadingMode="inline"
31
+ tone="subtle"
32
+ size="sm"
33
+ idleLabel="Add channel"
34
+ loadingLabel="Opening..."
35
+ idleIcon=${AddLineIcon}
36
+ idleIconClassName="h-3.5 w-3.5"
37
+ iconOnly=${true}
38
+ title=${title}
39
+ ariaLabel=${ariaLabel}
40
+ />
41
+ `}
42
+ >
43
+ ${channelIds.map((channelId) => {
44
+ const channelMeta = getChannelMeta(channelId);
45
+ const disabled = !!isChannelDisabled(channelId);
46
+ return html`
47
+ <${OverflowMenuItem}
48
+ key=${channelId}
49
+ iconSrc=${channelMeta.iconSrc}
50
+ disabled=${disabled}
51
+ onClick=${() => onSelectChannel(channelId)}
52
+ >
53
+ ${channelMeta.label}
54
+ </${OverflowMenuItem}>
55
+ `;
56
+ })}
57
+ </${OverflowMenu}>
58
+ `;
59
+
@@ -1,10 +1,11 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
+ import { isChannelProviderDisabledForAdd } from "../../../lib/channel-provider-availability.js";
4
+ import { AddChannelMenu } from "../../add-channel-menu.js";
3
5
  import { ActionButton } from "../../action-button.js";
4
6
  import { ALL_CHANNELS, ChannelsCard, getChannelMeta } from "../../channels.js";
5
7
  import { ConfirmDialog } from "../../confirm-dialog.js";
6
8
  import { AddLineIcon } from "../../icons.js";
7
- import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js";
8
9
  import { CreateChannelModal } from "../create-channel-modal.js";
9
10
  import { ChannelCardItem } from "./channel-item-trailing.js";
10
11
  import { useAgentBindings } from "./use-agent-bindings.js";
@@ -50,7 +51,7 @@ export const AgentBindingsSection = ({
50
51
  setShowCreateModal,
51
52
  showCreateModal,
52
53
  } = useAgentBindings({ agent, agents });
53
- const { hasDiscordAccount, mergedChannelItems } = useChannelItems({
54
+ const { mergedChannelItems } = useChannelItems({
54
55
  agentId,
55
56
  agentNameMap,
56
57
  channelStatus,
@@ -108,48 +109,23 @@ export const AgentBindingsSection = ({
108
109
  />`;
109
110
  }}
110
111
  actions=${html`
111
- <${OverflowMenu}
112
+ <${AddChannelMenu}
112
113
  open=${menuOpenId === "__create_channel"}
113
- ariaLabel="Add channel"
114
- title="Add channel"
115
114
  onClose=${() => setMenuOpenId("")}
116
115
  onToggle=${() =>
117
116
  setMenuOpenId((current) =>
118
117
  current === "__create_channel" ? "" : "__create_channel",
119
118
  )}
120
- renderTrigger=${({ onToggle, ariaLabel, title }) => html`
121
- <${ActionButton}
122
- onClick=${onToggle}
123
- disabled=${saving}
124
- loading=${false}
125
- loadingMode="inline"
126
- tone="subtle"
127
- size="sm"
128
- loadingLabel="Opening..."
129
- idleIcon=${AddLineIcon}
130
- idleIconClassName="h-3.5 w-3.5"
131
- iconOnly=${true}
132
- title=${title}
133
- ariaLabel=${ariaLabel}
134
- idleLabel="Add channel"
135
- />
136
- `}
137
- >
138
- ${ALL_CHANNELS.map((channelId) => {
139
- const channelMeta = getChannelMeta(channelId);
140
- const isDisabled = channelId === "discord" && hasDiscordAccount;
141
- return html`
142
- <${OverflowMenuItem}
143
- key=${channelId}
144
- iconSrc=${channelMeta.iconSrc}
145
- disabled=${isDisabled}
146
- onClick=${() => openCreateChannelModal(channelId)}
147
- >
148
- ${channelMeta.label}
149
- </${OverflowMenuItem}>
150
- `;
151
- })}
152
- </${OverflowMenu}>
119
+ triggerDisabled=${saving}
120
+ channelIds=${ALL_CHANNELS}
121
+ getChannelMeta=${getChannelMeta}
122
+ isChannelDisabled=${(channelId) =>
123
+ isChannelProviderDisabledForAdd({
124
+ configuredChannelMap,
125
+ provider: channelId,
126
+ })}
127
+ onSelectChannel=${openCreateChannelModal}
128
+ />
153
129
  `}
154
130
  />
155
131
  </div>
@@ -20,11 +20,6 @@ export const useChannelItems = ({
20
20
  defaultAgentId = "",
21
21
  isDefaultAgent = false,
22
22
  }) => {
23
- const hasDiscordAccount = useMemo(() => {
24
- const discordChannel = configuredChannelMap.get("discord");
25
- return Array.isArray(discordChannel?.accounts) && discordChannel.accounts.length > 0;
26
- }, [configuredChannelMap]);
27
-
28
23
  const [showAssignedElsewhere, setShowAssignedElsewhere] = useState(false);
29
24
 
30
25
  const channelItemData = useMemo(() => {
@@ -205,7 +200,6 @@ export const useChannelItems = ({
205
200
  }, [assignedElsewhereItems, showAssignedElsewhere, visibleChannelItems]);
206
201
 
207
202
  return {
208
- hasDiscordAccount,
209
203
  mergedChannelItems,
210
204
  };
211
205
  };
@@ -14,8 +14,28 @@ const html = htm.bind(h);
14
14
  const kChannelEnvKeys = {
15
15
  telegram: "TELEGRAM_BOT_TOKEN",
16
16
  discord: "DISCORD_BOT_TOKEN",
17
+ slack: "SLACK_BOT_TOKEN",
17
18
  };
18
19
 
20
+ const kChannelExtraEnvKeys = {
21
+ slack: "SLACK_APP_TOKEN",
22
+ };
23
+ const kSlackBotScopes = [
24
+ "app_mentions:read",
25
+ "channels:history",
26
+ "channels:read",
27
+ "chat:write",
28
+ "groups:history",
29
+ "im:history",
30
+ "im:read",
31
+ "im:write",
32
+ "mpim:history",
33
+ "reactions:read",
34
+ "reactions:write",
35
+ "users:read",
36
+ ];
37
+ const kSlackInstructionsLink = "https://docs.openclaw.ai/channels/slack";
38
+
19
39
  const slugifyChannelAccountId = (value) =>
20
40
  String(value || "")
21
41
  .toLowerCase()
@@ -50,6 +70,7 @@ export const CreateChannelModal = ({
50
70
  const [name, setName] = useState("");
51
71
  const [token, setToken] = useState("");
52
72
  const [initialToken, setInitialToken] = useState("");
73
+ const [appToken, setAppToken] = useState("");
53
74
  const [agentId, setAgentId] = useState("");
54
75
  const [error, setError] = useState("");
55
76
  const [nameEditedManually, setNameEditedManually] = useState(false);
@@ -66,22 +87,23 @@ export const CreateChannelModal = ({
66
87
  const nextSelectedChannel =
67
88
  existingChannels.find(
68
89
  (entry) =>
69
- String(entry?.channel || "").trim() === String(nextProvider || "").trim(),
90
+ String(entry?.channel || "").trim() ===
91
+ String(nextProvider || "").trim(),
70
92
  ) || null;
71
93
  const nextProviderHasAccounts =
72
- Array.isArray(nextSelectedChannel?.accounts)
73
- && nextSelectedChannel.accounts.length > 0;
94
+ Array.isArray(nextSelectedChannel?.accounts) &&
95
+ nextSelectedChannel.accounts.length > 0;
74
96
  const nextName = isEditMode
75
97
  ? String(account?.name || "").trim() || providerLabel
76
98
  : nextProviderHasAccounts
77
99
  ? ""
78
100
  : providerLabel;
79
101
  const nextAgentId = isEditMode
80
- ? String(account?.ownerAgentId || "").trim()
81
- || String(initialAgentId || "").trim()
82
- || String(agents[0]?.id || "").trim()
83
- : String(initialAgentId || "").trim()
84
- || String(agents[0]?.id || "").trim();
102
+ ? String(account?.ownerAgentId || "").trim() ||
103
+ String(initialAgentId || "").trim() ||
104
+ String(agents[0]?.id || "").trim()
105
+ : String(initialAgentId || "").trim() ||
106
+ String(agents[0]?.id || "").trim();
85
107
  setProvider(nextProvider);
86
108
  setName(nextName);
87
109
  const nextToken = isEditMode
@@ -92,6 +114,7 @@ export const CreateChannelModal = ({
92
114
  : "";
93
115
  setToken(nextToken);
94
116
  setInitialToken(nextToken);
117
+ setAppToken("");
95
118
  setAgentId(nextAgentId);
96
119
  setError("");
97
120
  setNameEditedManually(isEditMode);
@@ -108,13 +131,16 @@ export const CreateChannelModal = ({
108
131
  const selectedChannel = useMemo(
109
132
  () =>
110
133
  existingChannels.find(
111
- (entry) => String(entry?.channel || "").trim() === String(provider || "").trim(),
134
+ (entry) =>
135
+ String(entry?.channel || "").trim() === String(provider || "").trim(),
112
136
  ) || null,
113
137
  [existingChannels, provider],
114
138
  );
115
139
 
116
140
  const providerHasAccounts = useMemo(
117
- () => Array.isArray(selectedChannel?.accounts) && selectedChannel.accounts.length > 0,
141
+ () =>
142
+ Array.isArray(selectedChannel?.accounts) &&
143
+ selectedChannel.accounts.length > 0,
118
144
  [selectedChannel],
119
145
  );
120
146
  useEffect(() => {
@@ -126,7 +152,10 @@ export const CreateChannelModal = ({
126
152
  }
127
153
  setName(providerLabel);
128
154
  }, [provider, providerHasAccounts, nameEditedManually, isEditMode]);
129
- const isSingleAccountProvider = String(provider || "").trim() === "discord";
155
+ const isSingleAccountProvider =
156
+ String(provider || "").trim() === "discord" ||
157
+ String(provider || "").trim() === "slack";
158
+ const needsAppToken = String(provider || "").trim() === "slack";
130
159
 
131
160
  const accountId = useMemo(() => {
132
161
  if (isEditMode) {
@@ -144,9 +173,10 @@ export const CreateChannelModal = ({
144
173
 
145
174
  const accountExists = useMemo(
146
175
  () =>
147
- Array.isArray(selectedChannel?.accounts)
148
- && selectedChannel.accounts.some(
149
- (entry) => String(entry?.id || "").trim() === String(accountId || "").trim(),
176
+ Array.isArray(selectedChannel?.accounts) &&
177
+ selectedChannel.accounts.some(
178
+ (entry) =>
179
+ String(entry?.id || "").trim() === String(accountId || "").trim(),
150
180
  ),
151
181
  [selectedChannel, accountId],
152
182
  );
@@ -162,8 +192,10 @@ export const CreateChannelModal = ({
162
192
  });
163
193
  if (cancelled) return;
164
194
  const nextToken = String(result?.token || "");
195
+ const nextAppToken = String(result?.appToken || "");
165
196
  setToken(nextToken);
166
197
  setInitialToken(nextToken);
198
+ setAppToken(nextAppToken);
167
199
  } catch {
168
200
  // Keep existing fallback value.
169
201
  } finally {
@@ -179,13 +211,14 @@ export const CreateChannelModal = ({
179
211
  }, [visible, isEditMode, provider, accountId]);
180
212
 
181
213
  const canSubmit =
182
- !!String(provider || "").trim()
183
- && !!String(name || "").trim()
184
- && !!String(accountId || "").trim()
185
- && !!String(agentId || "").trim()
186
- && (isEditMode || !!String(token || "").trim())
187
- && (isEditMode || !accountExists)
188
- && !loadingToken;
214
+ !!String(provider || "").trim() &&
215
+ !!String(name || "").trim() &&
216
+ !!String(accountId || "").trim() &&
217
+ !!String(agentId || "").trim() &&
218
+ (isEditMode || !!String(token || "").trim()) &&
219
+ (isEditMode || !needsAppToken || !!String(appToken || "").trim()) &&
220
+ (isEditMode || !accountExists) &&
221
+ !loadingToken;
189
222
 
190
223
  if (!visible) return null;
191
224
 
@@ -202,6 +235,10 @@ export const CreateChannelModal = ({
202
235
  setError("Token is required");
203
236
  return;
204
237
  }
238
+ if (!isEditMode && needsAppToken && !String(appToken || "").trim()) {
239
+ setError("App Token is required for Slack");
240
+ return;
241
+ }
205
242
  if (!String(agentId || "").trim()) {
206
243
  setError("Agent is required");
207
244
  return;
@@ -213,13 +250,18 @@ export const CreateChannelModal = ({
213
250
 
214
251
  setError("");
215
252
  const trimmedToken = String(token || "").trim();
216
- const tokenWasUpdated = trimmedToken && trimmedToken !== String(initialToken || "").trim();
253
+ const tokenWasUpdated =
254
+ trimmedToken && trimmedToken !== String(initialToken || "").trim();
255
+ const trimmedAppToken = String(appToken || "").trim();
217
256
  await onSubmit({
218
257
  provider,
219
258
  name: String(name || "").trim(),
220
259
  accountId,
221
260
  agentId,
222
261
  ...(tokenWasUpdated ? { token: trimmedToken } : {}),
262
+ ...(needsAppToken && trimmedAppToken
263
+ ? { appToken: trimmedAppToken }
264
+ : {}),
223
265
  });
224
266
  };
225
267
 
@@ -230,9 +272,11 @@ export const CreateChannelModal = ({
230
272
  panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4"
231
273
  >
232
274
  <${PageHeader}
233
- title=${isEditMode
234
- ? "Edit Channel"
235
- : `Add ${getChannelMeta(provider).label || "Channel"} Channel`}
275
+ title=${
276
+ isEditMode
277
+ ? "Edit Channel"
278
+ : `Add ${getChannelMeta(provider).label || "Channel"} Channel`
279
+ }
236
280
  actions=${html`
237
281
  <button
238
282
  type="button"
@@ -269,18 +313,22 @@ export const CreateChannelModal = ({
269
313
  class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm font-mono text-gray-400 outline-none"
270
314
  />
271
315
  <p class="text-xs text-gray-500">
272
- ${isEditMode
273
- ? "Channel id is fixed after creation."
274
- : isSingleAccountProvider
275
- ? "Discord supports one channel account and uses the default id."
276
- : providerHasAccounts
277
- ? "Derived from the channel name."
278
- : "First account uses the default id for this provider."}
316
+ ${
317
+ isEditMode
318
+ ? "Channel id is fixed after creation."
319
+ : isSingleAccountProvider
320
+ ? `${getChannelMeta(provider).label} supports one channel account and uses the default id.`
321
+ : providerHasAccounts
322
+ ? "Derived from the channel name."
323
+ : "First account uses the default id for this provider."
324
+ }
279
325
  </p>
280
326
  </label>
281
327
 
282
328
  <label class="block space-y-1">
283
- <span class="text-xs text-gray-400">Token</span>
329
+ <span class="text-xs text-gray-400">
330
+ ${needsAppToken ? "Bot Token" : "Token"}
331
+ </span>
284
332
  <${SecretInput}
285
333
  value=${token}
286
334
  onInput=${(event) => setToken(event.target.value)}
@@ -295,6 +343,92 @@ export const CreateChannelModal = ({
295
343
  </p>
296
344
  </label>
297
345
 
346
+ ${
347
+ needsAppToken
348
+ ? html`
349
+ <label class="block space-y-1">
350
+ <span class="text-xs text-gray-400"
351
+ >App Token (Socket Mode)</span
352
+ >
353
+ <${SecretInput}
354
+ value=${appToken}
355
+ onInput=${(event) => setAppToken(event.target.value)}
356
+ placeholder="xapp-..."
357
+ isSecret=${true}
358
+ inputClass="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm font-mono text-gray-200 outline-none focus:border-gray-500"
359
+ />
360
+ <p class="text-xs text-gray-500">
361
+ Saved behind the scenes as
362
+ <code class="font-mono text-gray-400 ml-1">
363
+ ${kChannelExtraEnvKeys.slack}
364
+ </code>
365
+ .
366
+ </p>
367
+ </label>
368
+ `
369
+ : null
370
+ }
371
+ ${
372
+ needsAppToken
373
+ ? html`
374
+ <details class="rounded-lg border border-border bg-black/20 px-3 py-2.5">
375
+ <summary class="cursor-pointer text-xs text-gray-300 hover:text-gray-200">
376
+ Slack-specific instructions (step-by-step)
377
+ </summary>
378
+ <div class="mt-2 space-y-2 text-xs text-gray-500">
379
+ <ol class="list-decimal list-inside space-y-1.5">
380
+ <li>
381
+ In Slack app settings, turn on
382
+ ${" "}
383
+ <span class="text-gray-300">Socket Mode</span>.
384
+ </li>
385
+ <li>
386
+ In
387
+ ${" "}
388
+ <span class="text-gray-300">App Home</span>, enable
389
+ <code class="font-mono text-gray-400 ml-1">
390
+ Allow users to send Slash commands and messages from the messages tab
391
+ </code>.
392
+ </li>
393
+ <li>
394
+ In
395
+ ${" "}
396
+ <span class="text-gray-300">Event Subscriptions</span>, toggle on
397
+ <code class="font-mono text-gray-400 ml-1">Subscribe to bot events</code>
398
+ ${" "}
399
+ and add
400
+ <code class="font-mono text-gray-400 ml-1">message.im</code>.
401
+ </li>
402
+ <li>
403
+ Create a Bot Token (<code class="font-mono text-gray-400">xoxb-...</code>)
404
+ with scopes:
405
+ <code class="font-mono text-gray-400 ml-1">
406
+ ${kSlackBotScopes.join(", ")}
407
+ </code>
408
+ </li>
409
+ <li>
410
+ Create an App Token (<code class="font-mono text-gray-400">xapp-...</code>)
411
+ with
412
+ <code class="font-mono text-gray-400 ml-1">connections:write</code>.
413
+ </li>
414
+ <li>
415
+ Reinstall the app after changing scopes.
416
+ </li>
417
+ </ol>
418
+ <a
419
+ href=${kSlackInstructionsLink}
420
+ target="_blank"
421
+ class="hover:underline"
422
+ style="color: var(--accent-link)"
423
+ >
424
+ Open full Slack setup guide
425
+ </a>
426
+ </div>
427
+ </details>
428
+ `
429
+ : null
430
+ }
431
+
298
432
  <label class="block space-y-1">
299
433
  <span class="text-xs text-gray-400">Agent</span>
300
434
  <select
@@ -302,23 +436,27 @@ export const CreateChannelModal = ({
302
436
  onInput=${(event) => setAgentId(event.target.value)}
303
437
  class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
304
438
  >
305
- ${agents.map((agent) => html`
306
- <option key=${agent.id} value=${agent.id}>
307
- ${agent.name || agent.id}
308
- </option>
309
- `)}
439
+ ${agents.map(
440
+ (agent) => html`
441
+ <option key=${agent.id} value=${agent.id}>
442
+ ${agent.name || agent.id}
443
+ </option>
444
+ `,
445
+ )}
310
446
  </select>
311
447
  </label>
312
448
 
313
- ${!isEditMode && accountExists
314
- ? html`
315
- <p class="text-xs text-red-400">
316
- ${isSingleAccountProvider
317
- ? "Discord already has a configured channel account."
318
- : `A ${getChannelMeta(provider).label} account with this id already exists.`}
319
- </p>
320
- `
321
- : null}
449
+ ${
450
+ !isEditMode && accountExists
451
+ ? html`
452
+ <p class="text-xs text-red-400">
453
+ ${isSingleAccountProvider
454
+ ? `${getChannelMeta(provider).label} already has a configured channel account.`
455
+ : `A ${getChannelMeta(provider).label} account with this id already exists.`}
456
+ </p>
457
+ `
458
+ : null
459
+ }
322
460
  ${error ? html`<p class="text-xs text-red-400">${error}</p>` : null}
323
461
  </div>
324
462
 
@@ -6,10 +6,9 @@ import {
6
6
  useState,
7
7
  } from "https://esm.sh/preact/hooks";
8
8
  import htm from "https://esm.sh/htm";
9
- import { ActionButton } from "./action-button.js";
9
+ import { AddChannelMenu } from "./add-channel-menu.js";
10
10
  import { ChannelAccountStatusBadge } from "./channel-account-status-badge.js";
11
11
  import { ConfirmDialog } from "./confirm-dialog.js";
12
- import { AddLineIcon } from "./icons.js";
13
12
  import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
14
13
  import {
15
14
  deleteChannelAccount,
@@ -21,15 +20,17 @@ import {
21
20
  resolveChannelAccountLabel,
22
21
  } from "../lib/channel-accounts.js";
23
22
  import { createChannelAccountWithProgress } from "../lib/channel-create-operation.js";
23
+ import { isChannelProviderDisabledForAdd } from "../lib/channel-provider-availability.js";
24
24
  import { CreateChannelModal } from "./agents-tab/create-channel-modal.js";
25
25
  import { showToast } from "./toast.js";
26
26
 
27
27
  const html = htm.bind(h);
28
28
 
29
- const ALL_CHANNELS = ["telegram", "discord"];
29
+ const ALL_CHANNELS = ["telegram", "discord", "slack"];
30
30
  const kChannelMeta = {
31
31
  telegram: { label: "Telegram", iconSrc: "/assets/icons/telegram.svg" },
32
32
  discord: { label: "Discord", iconSrc: "/assets/icons/discord.svg" },
33
+ slack: { label: "Slack", iconSrc: "/assets/icons/slack.svg" },
33
34
  };
34
35
 
35
36
  const getChannelMeta = (channelId = "") => {
@@ -271,11 +272,6 @@ export const Channels = ({
271
272
  mode: "create",
272
273
  });
273
274
  };
274
- const hasDiscordAccount = useMemo(() => {
275
- const discordChannel = configuredChannelMap.get("discord");
276
- return Array.isArray(discordChannel?.accounts) && discordChannel.accounts.length > 0;
277
- }, [configuredChannelMap]);
278
-
279
275
  const items = useMemo(
280
276
  () => {
281
277
  if (loadingAccounts || !channels) return [];
@@ -477,48 +473,23 @@ export const Channels = ({
477
473
  ? "Loading..."
478
474
  : "No channels configured"}
479
475
  actions=${html`
480
- <${OverflowMenu}
476
+ <${AddChannelMenu}
481
477
  open=${menuOpenId === "__create_channel"}
482
- ariaLabel="Add channel"
483
- title="Add channel"
484
478
  onClose=${() => setMenuOpenId("")}
485
479
  onToggle=${() =>
486
480
  setMenuOpenId((current) =>
487
481
  current === "__create_channel" ? "" : "__create_channel",
488
482
  )}
489
- renderTrigger=${({ onToggle, ariaLabel, title }) => html`
490
- <${ActionButton}
491
- onClick=${onToggle}
492
- disabled=${saving || loadingAccounts}
493
- loading=${false}
494
- loadingMode="inline"
495
- tone="subtle"
496
- size="sm"
497
- idleLabel="Add channel"
498
- loadingLabel="Opening..."
499
- idleIcon=${AddLineIcon}
500
- idleIconClassName="h-3.5 w-3.5"
501
- iconOnly=${true}
502
- title=${title}
503
- ariaLabel=${ariaLabel}
504
- />
505
- `}
506
- >
507
- ${ALL_CHANNELS.map((channelId) => {
508
- const channelMeta = getChannelMeta(channelId);
509
- const isDisabled = channelId === "discord" && hasDiscordAccount;
510
- return html`
511
- <${OverflowMenuItem}
512
- key=${channelId}
513
- iconSrc=${channelMeta.iconSrc}
514
- disabled=${isDisabled}
515
- onClick=${() => openCreateChannelModal(channelId)}
516
- >
517
- ${channelMeta.label}
518
- </${OverflowMenuItem}>
519
- `;
520
- })}
521
- </${OverflowMenu}>
483
+ triggerDisabled=${saving || loadingAccounts}
484
+ channelIds=${ALL_CHANNELS}
485
+ getChannelMeta=${getChannelMeta}
486
+ isChannelDisabled=${(channelId) =>
487
+ isChannelProviderDisabledForAdd({
488
+ configuredChannelMap,
489
+ provider: channelId,
490
+ })}
491
+ onSelectChannel=${openCreateChannelModal}
492
+ />
522
493
  `}
523
494
  />
524
495
  <${CreateChannelModal}