@chrysb/alphaclaw 0.6.0-beta.0 → 0.6.0-beta.2

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,203 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { Badge } from "../../badge.js";
4
+ import { ChannelAccountStatusBadge } from "../../channel-account-status-badge.js";
5
+ import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js";
6
+
7
+ const html = htm.bind(h);
8
+
9
+ export const ChannelItemTrailing = ({
10
+ item = {},
11
+ menuOpenId = "",
12
+ setMenuOpenId = () => {},
13
+ openDeleteChannelDialog = () => {},
14
+ openEditChannelModal = () => {},
15
+ requestBindAccount = () => {},
16
+ onSetLocation = () => {},
17
+ }) => {
18
+ const {
19
+ accountData = {},
20
+ accountId = "",
21
+ accountStatusInfo = {},
22
+ canNavigateToOwnerAgent = false,
23
+ channel = "",
24
+ ownerAgentId = "",
25
+ ownerAgentName = "",
26
+ isAvailable = false,
27
+ isOwned = false,
28
+ } = item;
29
+
30
+ let statusTrailing = null;
31
+ if (isOwned) {
32
+ statusTrailing =
33
+ accountStatusInfo?.status === "paired"
34
+ ? html`<${ChannelAccountStatusBadge}
35
+ status=${accountStatusInfo?.status}
36
+ ownerAgentName=${ownerAgentName}
37
+ showAgentBadge=${true}
38
+ channelId=${channel}
39
+ pairedCount=${accountStatusInfo?.paired ?? 0}
40
+ />`
41
+ : html`<${ChannelAccountStatusBadge}
42
+ status=${accountStatusInfo?.status}
43
+ ownerAgentName=""
44
+ showAgentBadge=${false}
45
+ channelId=${channel}
46
+ pairedCount=${accountStatusInfo?.paired ?? 0}
47
+ />`;
48
+ } else if (isAvailable) {
49
+ statusTrailing = html`
50
+ <button
51
+ type="button"
52
+ onclick=${(event) => {
53
+ event.stopPropagation();
54
+ requestBindAccount(accountData);
55
+ }}
56
+ class="text-xs px-2 py-1 rounded-lg ac-btn-ghost"
57
+ >
58
+ Bind
59
+ </button>
60
+ `;
61
+ } else {
62
+ statusTrailing = html`
63
+ ${canNavigateToOwnerAgent
64
+ ? html`
65
+ <button
66
+ type="button"
67
+ class="inline-flex rounded-full transition-[filter] hover:brightness-125 focus:outline-none focus:ring-1 focus:ring-border"
68
+ onclick=${(event) => {
69
+ event.stopPropagation();
70
+ onSetLocation(`/agents/${encodeURIComponent(ownerAgentId)}`);
71
+ }}
72
+ title=${`Open ${ownerAgentName}`}
73
+ aria-label=${`Open ${ownerAgentName}`}
74
+ >
75
+ <${Badge} tone="neutral">${ownerAgentName}</${Badge}>
76
+ </button>
77
+ `
78
+ : html`<${Badge} tone="neutral">${ownerAgentName || "Bound elsewhere"}</${Badge}>`}
79
+ `;
80
+ }
81
+
82
+ const showBindAction = accountData.isBoundElsewhere;
83
+ const canEditOrDelete = !accountData.isBoundElsewhere;
84
+
85
+ return html`
86
+ <div class="flex items-center gap-1.5">
87
+ ${statusTrailing}
88
+ <${OverflowMenu}
89
+ open=${menuOpenId === `${channel}:${accountId}`}
90
+ ariaLabel="Open channel actions"
91
+ title="Open channel actions"
92
+ onClose=${() => setMenuOpenId("")}
93
+ onToggle=${() =>
94
+ setMenuOpenId((current) =>
95
+ current === `${channel}:${accountId}`
96
+ ? ""
97
+ : `${channel}:${accountId}`,
98
+ )}
99
+ >
100
+ ${canEditOrDelete
101
+ ? html`
102
+ <${OverflowMenuItem}
103
+ onClick=${() => openEditChannelModal(accountData)}
104
+ >
105
+ Edit
106
+ </${OverflowMenuItem}>
107
+ `
108
+ : null}
109
+ ${showBindAction
110
+ ? html`
111
+ <${OverflowMenuItem}
112
+ onClick=${() => requestBindAccount(accountData)}
113
+ >
114
+ Bind
115
+ </${OverflowMenuItem}>
116
+ `
117
+ : null}
118
+ ${canEditOrDelete
119
+ ? html`
120
+ <${OverflowMenuItem}
121
+ className="text-red-300 hover:text-red-200"
122
+ onClick=${() => openDeleteChannelDialog(accountData)}
123
+ >
124
+ Delete
125
+ </${OverflowMenuItem}>
126
+ `
127
+ : null}
128
+ </${OverflowMenu}>
129
+ </div>
130
+ `;
131
+ };
132
+
133
+ export const ChannelCardItem = ({
134
+ item = {},
135
+ channelMeta = {},
136
+ menuOpenId = "",
137
+ setMenuOpenId = () => {},
138
+ openDeleteChannelDialog = () => {},
139
+ openEditChannelModal = () => {},
140
+ requestBindAccount = () => {},
141
+ onSetLocation = () => {},
142
+ }) => {
143
+ const canOpenWorkspace = !!item?.canOpenWorkspace;
144
+ const accountId = String(item?.accountId || "").trim() || "default";
145
+ return html`
146
+ <div
147
+ key=${item.id || item.channel}
148
+ class="flex justify-between items-center py-1.5 ${canOpenWorkspace
149
+ ? "cursor-pointer hover:bg-white/5 -mx-2 px-2 rounded-lg transition-colors"
150
+ : ""}"
151
+ onclick=${canOpenWorkspace
152
+ ? () => onSetLocation(`/telegram/${encodeURIComponent(accountId)}`)
153
+ : undefined}
154
+ >
155
+ <span class="font-medium text-sm flex items-center gap-2 min-w-0">
156
+ ${channelMeta?.iconSrc
157
+ ? html`
158
+ <img
159
+ src=${channelMeta.iconSrc}
160
+ alt=""
161
+ class="w-4 h-4 rounded-sm"
162
+ aria-hidden="true"
163
+ />
164
+ `
165
+ : null}
166
+ <span class="truncate ${item?.dimmedLabel ? "text-gray-500" : ""} ${item?.labelClassName || ""}">
167
+ ${item?.label || channelMeta?.label || "Channel"}
168
+ </span>
169
+ ${canOpenWorkspace
170
+ ? html`
171
+ <span class="text-xs text-gray-500 ml-1 shrink-0">Workspace</span>
172
+ <svg
173
+ width="14"
174
+ height="14"
175
+ viewBox="0 0 16 16"
176
+ fill="none"
177
+ class="text-gray-600 shrink-0"
178
+ >
179
+ <path
180
+ d="M6 3.5L10.5 8L6 12.5"
181
+ stroke="currentColor"
182
+ stroke-width="2"
183
+ stroke-linecap="round"
184
+ stroke-linejoin="round"
185
+ />
186
+ </svg>
187
+ `
188
+ : null}
189
+ </span>
190
+ <span class="flex items-center gap-2 shrink-0">
191
+ <${ChannelItemTrailing}
192
+ item=${item}
193
+ menuOpenId=${menuOpenId}
194
+ setMenuOpenId=${setMenuOpenId}
195
+ openDeleteChannelDialog=${openDeleteChannelDialog}
196
+ openEditChannelModal=${openEditChannelModal}
197
+ requestBindAccount=${requestBindAccount}
198
+ onSetLocation=${onSetLocation}
199
+ />
200
+ </span>
201
+ </div>
202
+ `;
203
+ };
@@ -1,3 +1,8 @@
1
+ import {
2
+ isImplicitDefaultAccount,
3
+ resolveChannelAccountLabel,
4
+ } from "../../../lib/channel-accounts.js";
5
+
1
6
  export const announceBindingsChanged = (agentId) => {
2
7
  window.dispatchEvent(
3
8
  new CustomEvent("alphaclaw:agent-bindings-changed", {
@@ -6,17 +11,12 @@ export const announceBindingsChanged = (agentId) => {
6
11
  );
7
12
  };
8
13
 
9
- export const resolveChannelAccountLabel = ({ channelId, account = {} }) => {
10
- const providerLabel = channelId
11
- ? channelId.charAt(0).toUpperCase() + channelId.slice(1)
12
- : "Channel";
13
- const configuredName = String(account?.name || "").trim();
14
- if (configuredName) return configuredName;
15
- const accountId = String(account?.id || "").trim();
16
- if (!accountId || accountId === "default") return providerLabel;
17
- return `${providerLabel} ${accountId}`;
14
+ export const announceRestartRequired = () => {
15
+ window.dispatchEvent(new CustomEvent("alphaclaw:restart-required"));
18
16
  };
19
17
 
18
+ export { resolveChannelAccountLabel };
19
+
20
20
  export const getChannelItemSortRank = (item = {}) => {
21
21
  if (item.isAwaitingPairing) return 99;
22
22
  if (item.isOwned) return 0;
@@ -55,9 +55,7 @@ export const getResolvedAccountStatusInfo = ({
55
55
  return getAccountStatusInfo({ statusInfo, accountId });
56
56
  };
57
57
 
58
- export const isImplicitDefaultAccount = ({ accountId, boundAgentId }) =>
59
- String(accountId || "").trim() === "default" &&
60
- !String(boundAgentId || "").trim();
58
+ export { isImplicitDefaultAccount };
61
59
 
62
60
  export const canAgentBindAccount = ({
63
61
  accountId,
@@ -1,22 +1,14 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
2
  import htm from "https://esm.sh/htm";
4
3
  import { ActionButton } from "../../action-button.js";
5
- import { Badge } from "../../badge.js";
6
- import { ChannelAccountStatusBadge } from "../../channel-account-status-badge.js";
7
4
  import { ALL_CHANNELS, ChannelsCard, getChannelMeta } from "../../channels.js";
8
5
  import { ConfirmDialog } from "../../confirm-dialog.js";
9
6
  import { AddLineIcon } from "../../icons.js";
10
7
  import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js";
11
8
  import { CreateChannelModal } from "../create-channel-modal.js";
12
- import {
13
- canAgentBindAccount,
14
- getChannelItemSortRank,
15
- getResolvedAccountStatusInfo,
16
- isImplicitDefaultAccount,
17
- resolveChannelAccountLabel,
18
- } from "./helpers.js";
9
+ import { ChannelCardItem } from "./channel-item-trailing.js";
19
10
  import { useAgentBindings } from "./use-agent-bindings.js";
11
+ import { useChannelItems } from "./use-channel-items.js";
20
12
 
21
13
  const html = htm.bind(h);
22
14
 
@@ -58,232 +50,7 @@ export const AgentBindingsSection = ({
58
50
  setShowCreateModal,
59
51
  showCreateModal,
60
52
  } = useAgentBindings({ agent, agents });
61
- const hasDiscordAccount = useMemo(() => {
62
- const discordChannel = configuredChannelMap.get("discord");
63
- return Array.isArray(discordChannel?.accounts) && discordChannel.accounts.length > 0;
64
- }, [configuredChannelMap]);
65
- const [showAssignedElsewhere, setShowAssignedElsewhere] = useState(false);
66
-
67
- const channelItems = useMemo(() => {
68
- const channelOrderMap = new Map(
69
- configuredChannels.map((entry, index) => [
70
- String(entry?.channel || "").trim(),
71
- index,
72
- ]),
73
- );
74
- const accountOrderMap = new Map(
75
- configuredChannels.flatMap((entry) =>
76
- (Array.isArray(entry?.accounts) ? entry.accounts : []).map(
77
- (account, accountIndex) => [
78
- `${String(entry?.channel || "").trim()}:${String(account?.id || "").trim() || "default"}`,
79
- accountIndex,
80
- ],
81
- ),
82
- ),
83
- );
84
- const channelIds = Array.from(
85
- new Set([
86
- ...configuredChannels.map((entry) => String(entry.channel || "").trim()),
87
- ]),
88
- ).filter(Boolean);
89
-
90
- return channelIds
91
- .flatMap((channelId) => {
92
- const configuredChannel = configuredChannelMap.get(channelId);
93
- const statusInfo = channelStatus?.[channelId] || null;
94
- const accounts = Array.isArray(configuredChannel?.accounts)
95
- ? configuredChannel.accounts
96
- : [];
97
-
98
- if (!configuredChannel && !statusInfo) return [];
99
-
100
- return accounts.map((account) => {
101
- const accountId = String(account?.id || "").trim() || "default";
102
- const boundAgentId = String(account?.boundAgentId || "").trim();
103
- const accountStatusInfo = getResolvedAccountStatusInfo({
104
- account,
105
- statusInfo,
106
- accountId,
107
- });
108
- const isImplicitDefaultOwned =
109
- isDefaultAgent &&
110
- isImplicitDefaultAccount({ accountId, boundAgentId });
111
- const isOwned = boundAgentId === agentId || isImplicitDefaultOwned;
112
- const isImplicitDefaultElsewhere =
113
- !isDefaultAgent &&
114
- isImplicitDefaultAccount({ accountId, boundAgentId });
115
- const isAvailable = canAgentBindAccount({
116
- accountId,
117
- boundAgentId,
118
- agentId,
119
- isDefaultAgent,
120
- });
121
- const ownerAgentId =
122
- boundAgentId ||
123
- (isImplicitDefaultAccount({ accountId, boundAgentId })
124
- ? defaultAgentId
125
- : "");
126
- const ownerAgentName = String(
127
- agentNameMap.get(ownerAgentId) || ownerAgentId || "",
128
- ).trim();
129
- const canNavigateToOwnerAgent =
130
- !!ownerAgentId && ownerAgentId !== agentId && !!ownerAgentName;
131
- const canOpenWorkspace =
132
- channelId === "telegram" &&
133
- isOwned &&
134
- accountStatusInfo?.status === "paired";
135
-
136
- const accountData = {
137
- id: accountId,
138
- provider: channelId,
139
- name: resolveChannelAccountLabel({ channelId, account }),
140
- rawName: String(account?.name || "").trim(),
141
- ownerAgentId,
142
- ownerAgentName,
143
- boundAgentId,
144
- isOwned,
145
- envKey: String(account?.envKey || "").trim(),
146
- token: String(account?.token || "").trim(),
147
- isAvailable,
148
- isBoundElsewhere:
149
- !isOwned &&
150
- (!isAvailable || isImplicitDefaultElsewhere || !!ownerAgentId),
151
- };
152
- let statusTrailing = null;
153
- if (isOwned) {
154
- statusTrailing =
155
- accountStatusInfo?.status === "paired"
156
- ? html`<${ChannelAccountStatusBadge}
157
- status=${accountStatusInfo?.status}
158
- ownerAgentName=${ownerAgentName}
159
- showAgentBadge=${true}
160
- channelId=${channelId}
161
- pairedCount=${accountStatusInfo?.paired ?? 0}
162
- />`
163
- : html`<${ChannelAccountStatusBadge}
164
- status=${accountStatusInfo?.status}
165
- ownerAgentName=""
166
- showAgentBadge=${false}
167
- channelId=${channelId}
168
- pairedCount=${accountStatusInfo?.paired ?? 0}
169
- />`;
170
- } else if (isAvailable) {
171
- statusTrailing = html`
172
- <button
173
- type="button"
174
- onclick=${(event) => {
175
- event.stopPropagation();
176
- requestBindAccount(accountData);
177
- }}
178
- class="text-xs px-2 py-1 rounded-lg ac-btn-ghost"
179
- >
180
- Bind
181
- </button>
182
- `;
183
- } else {
184
- statusTrailing = html`
185
- ${canNavigateToOwnerAgent
186
- ? html`
187
- <button
188
- type="button"
189
- class="inline-flex rounded-full transition-[filter] hover:brightness-125 focus:outline-none focus:ring-1 focus:ring-border"
190
- onclick=${(event) => {
191
- event.stopPropagation();
192
- onSetLocation(`/agents/${encodeURIComponent(ownerAgentId)}`);
193
- }}
194
- title=${`Open ${ownerAgentName}`}
195
- aria-label=${`Open ${ownerAgentName}`}
196
- >
197
- <${Badge} tone="neutral">${ownerAgentName}</${Badge}>
198
- </button>
199
- `
200
- : html`<${Badge} tone="neutral">${ownerAgentName || "Bound elsewhere"}</${Badge}>`}
201
- `;
202
- }
203
-
204
- const showBindAction = accountData.isBoundElsewhere;
205
- const canEditOrDelete = !accountData.isBoundElsewhere;
206
- const accountTrailing = html`
207
- <div class="flex items-center gap-1.5">
208
- ${statusTrailing}
209
- <${OverflowMenu}
210
- open=${menuOpenId === `${channelId}:${accountId}`}
211
- ariaLabel="Open channel actions"
212
- title="Open channel actions"
213
- onClose=${() => setMenuOpenId("")}
214
- onToggle=${() =>
215
- setMenuOpenId((current) =>
216
- current === `${channelId}:${accountId}`
217
- ? ""
218
- : `${channelId}:${accountId}`,
219
- )}
220
- >
221
- ${canEditOrDelete
222
- ? html`
223
- <${OverflowMenuItem}
224
- onClick=${() => openEditChannelModal(accountData)}
225
- >
226
- Edit
227
- </${OverflowMenuItem}>
228
- `
229
- : null}
230
- ${showBindAction
231
- ? html`
232
- <${OverflowMenuItem}
233
- onClick=${() => requestBindAccount(accountData)}
234
- >
235
- Bind
236
- </${OverflowMenuItem}>
237
- `
238
- : null}
239
- ${canEditOrDelete
240
- ? html`
241
- <${OverflowMenuItem}
242
- className="text-red-300 hover:text-red-200"
243
- onClick=${() => openDeleteChannelDialog(accountData)}
244
- >
245
- Delete
246
- </${OverflowMenuItem}>
247
- `
248
- : null}
249
- </${OverflowMenu}>
250
- </div>
251
- `;
252
-
253
- return {
254
- id: `${channelId}:${accountId}`,
255
- channel: channelId,
256
- channelOrder: Number(channelOrderMap.get(channelId) ?? 9999),
257
- accountOrder: Number(
258
- accountOrderMap.get(`${channelId}:${accountId}`) ?? 9999,
259
- ),
260
- label: resolveChannelAccountLabel({ channelId, account }),
261
- isAwaitingPairing: accountStatusInfo?.status !== "paired",
262
- clickable: canOpenWorkspace,
263
- onClick: canOpenWorkspace ? () => onSetLocation(`/telegram/${encodeURIComponent(account?.id || "default")}`) : undefined,
264
- detailText: canOpenWorkspace ? "Workspace" : "",
265
- detailChevron: canOpenWorkspace,
266
- trailing: accountTrailing,
267
- isOwned,
268
- isAvailable,
269
- dimmedLabel: accountData.isBoundElsewhere,
270
- isBoundElsewhere: accountData.isBoundElsewhere,
271
- };
272
- });
273
- })
274
- .filter(Boolean)
275
- .sort((a, b) => {
276
- const rankDiff = getChannelItemSortRank(a) - getChannelItemSortRank(b);
277
- if (rankDiff !== 0) return rankDiff;
278
- const channelOrderDiff =
279
- Number(a?.channelOrder ?? 9999) - Number(b?.channelOrder ?? 9999);
280
- if (channelOrderDiff !== 0) return channelOrderDiff;
281
- const accountOrderDiff =
282
- Number(a?.accountOrder ?? 9999) - Number(b?.accountOrder ?? 9999);
283
- if (accountOrderDiff !== 0) return accountOrderDiff;
284
- return String(a?.label || "").localeCompare(String(b?.label || ""));
285
- });
286
- }, [
53
+ const { hasDiscordAccount, mergedChannelItems } = useChannelItems({
287
54
  agentId,
288
55
  agentNameMap,
289
56
  channelStatus,
@@ -291,57 +58,7 @@ export const AgentBindingsSection = ({
291
58
  configuredChannels,
292
59
  defaultAgentId,
293
60
  isDefaultAgent,
294
- menuOpenId,
295
- onSetLocation,
296
- openCreateChannelModal,
297
- openDeleteChannelDialog,
298
- openEditChannelModal,
299
- requestBindAccount,
300
- setMenuOpenId,
301
- ]);
302
- const visibleChannelItems = channelItems.filter(
303
- (item) => !item?.isBoundElsewhere,
304
- );
305
- const assignedElsewhereItems = channelItems.filter(
306
- (item) => !!item?.isBoundElsewhere,
307
- );
308
- useEffect(() => {
309
- if (assignedElsewhereItems.length === 0) {
310
- setShowAssignedElsewhere(false);
311
- return;
312
- }
313
- if (visibleChannelItems.length === 0) {
314
- setShowAssignedElsewhere(true);
315
- }
316
- }, [agentId, assignedElsewhereItems.length, visibleChannelItems.length]);
317
- const mergedChannelItems = useMemo(() => {
318
- const baseItems = [...visibleChannelItems];
319
- if (assignedElsewhereItems.length === 0) return baseItems;
320
- baseItems.push({
321
- id: "__assigned_elsewhere_toggle",
322
- label: html`
323
- <span class="inline-flex items-center gap-1.5">
324
- <span class=${`arrow inline-block ${showAssignedElsewhere ? "" : "-rotate-90"}`}>▼</span>
325
- <span>Assigned elsewhere</span>
326
- </span>
327
- `,
328
- labelClassName: "text-xs",
329
- clickable: true,
330
- onClick: () => setShowAssignedElsewhere((current) => !current),
331
- dimmedLabel: true,
332
- trailing: html`
333
- <span class="inline-flex items-center gap-1.5 text-gray-500">
334
- <span class="text-[11px] px-2 py-0.5 rounded-full border border-border">
335
- ${assignedElsewhereItems.length}
336
- </span>
337
- </span>
338
- `,
339
- });
340
- if (showAssignedElsewhere) {
341
- baseItems.push(...assignedElsewhereItems);
342
- }
343
- return baseItems;
344
- }, [assignedElsewhereItems, showAssignedElsewhere, visibleChannelItems]);
61
+ });
345
62
 
346
63
  return html`
347
64
  <div class="space-y-3">
@@ -375,6 +92,21 @@ export const AgentBindingsSection = ({
375
92
  title="Channels"
376
93
  items=${mergedChannelItems}
377
94
  loadingLabel="No channels assigned to this agent."
95
+ renderItem=${({ item, channelMeta }) => {
96
+ if (String(item?.id || "").trim() === "__assigned_elsewhere_toggle") {
97
+ return null;
98
+ }
99
+ return html`<${ChannelCardItem}
100
+ item=${item}
101
+ channelMeta=${channelMeta}
102
+ menuOpenId=${menuOpenId}
103
+ setMenuOpenId=${setMenuOpenId}
104
+ openDeleteChannelDialog=${openDeleteChannelDialog}
105
+ openEditChannelModal=${openEditChannelModal}
106
+ requestBindAccount=${requestBindAccount}
107
+ onSetLocation=${onSetLocation}
108
+ />`;
109
+ }}
378
110
  actions=${html`
379
111
  <${OverflowMenu}
380
112
  open=${menuOpenId === "__create_channel"}
@@ -12,7 +12,7 @@ import {
12
12
  } from "../../../lib/api.js";
13
13
  import { createChannelAccountWithProgress } from "../../../lib/channel-create-operation.js";
14
14
  import { showToast } from "../../toast.js";
15
- import { announceBindingsChanged } from "./helpers.js";
15
+ import { announceBindingsChanged, announceRestartRequired } from "./helpers.js";
16
16
 
17
17
  export const useAgentBindings = ({ agent = {}, agents = [] }) => {
18
18
  const [channels, setChannels] = useState([]);