@chrysb/alphaclaw 0.6.2-beta.5 → 0.7.0-beta.0

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.
Files changed (33) hide show
  1. package/lib/public/css/agents.css +37 -13
  2. package/lib/public/css/cron.css +124 -41
  3. package/lib/public/css/shell.css +61 -2
  4. package/lib/public/css/theme.css +2 -1
  5. package/lib/public/js/app.js +41 -33
  6. package/lib/public/js/components/agents-tab/agent-detail-panel.js +61 -49
  7. package/lib/public/js/components/agents-tab/agent-overview/index.js +9 -0
  8. package/lib/public/js/components/agents-tab/agent-overview/tools-card.js +54 -0
  9. package/lib/public/js/components/cron-tab/cron-calendar.js +297 -203
  10. package/lib/public/js/components/cron-tab/cron-helpers.js +48 -0
  11. package/lib/public/js/components/cron-tab/cron-insights-panel.js +294 -0
  12. package/lib/public/js/components/cron-tab/cron-job-detail.js +38 -363
  13. package/lib/public/js/components/cron-tab/cron-job-settings-card.js +233 -0
  14. package/lib/public/js/components/cron-tab/cron-overview.js +40 -19
  15. package/lib/public/js/components/cron-tab/cron-prompt-editor.js +173 -0
  16. package/lib/public/js/components/cron-tab/cron-run-history-panel.js +74 -62
  17. package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +24 -24
  18. package/lib/public/js/components/cron-tab/index.js +170 -78
  19. package/lib/public/js/components/envars.js +187 -46
  20. package/lib/public/js/components/file-viewer/editor-surface.js +5 -1
  21. package/lib/public/js/components/file-viewer/use-editor-line-number-sync.js +36 -0
  22. package/lib/public/js/components/file-viewer/use-file-viewer.js +7 -23
  23. package/lib/public/js/components/file-viewer/utils.js +1 -5
  24. package/lib/public/js/components/models-tab/index.js +137 -133
  25. package/lib/public/js/components/models-tab/provider-auth-card.js +8 -1
  26. package/lib/public/js/components/models-tab/use-models.js +35 -8
  27. package/lib/public/js/components/onboarding/welcome-pairing-step.js +88 -59
  28. package/lib/public/js/components/pane-shell.js +27 -0
  29. package/lib/public/js/components/routes/envars-route.js +1 -3
  30. package/lib/public/js/components/routes/models-route.js +1 -3
  31. package/lib/public/js/lib/app-navigation.js +1 -1
  32. package/lib/server/cost-utils.js +2 -2
  33. package/package.json +1 -1
@@ -32,6 +32,7 @@ import { useFileDiff } from "./use-file-diff.js";
32
32
  import { useFileViewerDraftSync } from "./use-file-viewer-draft-sync.js";
33
33
  import { useFileViewerHotkeys } from "./use-file-viewer-hotkeys.js";
34
34
  import { useEditorSelectionRestore } from "./use-editor-selection-restore.js";
35
+ import { useEditorLineNumberSync } from "./use-editor-line-number-sync.js";
35
36
 
36
37
  export const useFileViewer = ({
37
38
  filePath = "",
@@ -190,29 +191,12 @@ export const useFileViewer = ({
190
191
  [parsedFrontmatter.body, isMarkdownFile],
191
192
  );
192
193
 
193
- const syncEditorLineNumberHeights = useCallback(() => {
194
- if (!shouldUseHighlightedEditor || viewMode !== "edit") return;
195
- const numberRows = editorLineNumberRowRefs.current;
196
- const highlightRows = editorHighlightLineRefs.current;
197
- const rowCount = Math.min(numberRows.length, highlightRows.length);
198
- for (let index = 0; index < rowCount; index += 1) {
199
- const numberRow = numberRows[index];
200
- const highlightRow = highlightRows[index];
201
- if (!numberRow || !highlightRow) continue;
202
- numberRow.style.height = `${highlightRow.offsetHeight}px`;
203
- }
204
- }, [shouldUseHighlightedEditor, viewMode]);
205
-
206
- useEffect(() => {
207
- syncEditorLineNumberHeights();
208
- }, [content, syncEditorLineNumberHeights]);
209
-
210
- useEffect(() => {
211
- if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {};
212
- const onResize = () => syncEditorLineNumberHeights();
213
- window.addEventListener("resize", onResize);
214
- return () => window.removeEventListener("resize", onResize);
215
- }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]);
194
+ useEditorLineNumberSync({
195
+ enabled: shouldUseHighlightedEditor && viewMode === "edit",
196
+ syncKey: `${normalizedPath}:${renderContent.length}:${highlightedEditorLines.length}`,
197
+ editorLineNumberRowRefs,
198
+ editorHighlightLineRefs,
199
+ });
216
200
 
217
201
  useEffect(() => {
218
202
  if (!isMarkdownFile && viewMode !== "edit") {
@@ -13,11 +13,7 @@ export const clampSelectionIndex = (value, maxValue) => {
13
13
  export const countTextLines = (content) => {
14
14
  const text = String(content || "");
15
15
  if (!text) return 1;
16
- let lineCount = 1;
17
- for (let index = 0; index < text.length; index += 1) {
18
- if (text.charCodeAt(index) === 10) lineCount += 1;
19
- }
20
- return lineCount;
16
+ return text.split(/\r\n|\r|\n/).length;
21
17
  };
22
18
 
23
19
  export const shouldUseSimpleEditorMode = ({
@@ -4,6 +4,8 @@ import htm from "https://esm.sh/htm";
4
4
  import { PageHeader } from "../page-header.js";
5
5
  import { LoadingSpinner } from "../loading-spinner.js";
6
6
  import { ActionButton } from "../action-button.js";
7
+ import { PopActions } from "../pop-actions.js";
8
+ import { PaneShell } from "../pane-shell.js";
7
9
  import { Badge } from "../badge.js";
8
10
  import { useModels } from "./use-models.js";
9
11
  import {
@@ -123,155 +125,157 @@ export const Models = ({ onRestartRequired = () => {}, agentId, embedded = false
123
125
  );
124
126
 
125
127
  const headerActions = html`
126
- <${ActionButton}
127
- onClick=${cancelChanges}
128
- disabled=${!isDirty || saving}
129
- tone="secondary"
130
- size="sm"
131
- idleLabel="Cancel"
132
- className="transition-all"
133
- />
134
- <${ActionButton}
135
- onClick=${saveAll}
136
- disabled=${!isDirty || saving}
137
- loading=${saving}
138
- tone="primary"
139
- size="sm"
140
- idleLabel="Save changes"
141
- loadingLabel="Saving..."
142
- className="transition-all"
143
- />
128
+ <${PopActions} visible=${isDirty}>
129
+ <${ActionButton}
130
+ onClick=${cancelChanges}
131
+ disabled=${saving}
132
+ tone="secondary"
133
+ size="sm"
134
+ idleLabel="Cancel"
135
+ className="text-xs"
136
+ />
137
+ <${ActionButton}
138
+ onClick=${saveAll}
139
+ disabled=${saving}
140
+ loading=${saving}
141
+ loadingMode="inline"
142
+ tone="primary"
143
+ size="sm"
144
+ idleLabel="Save changes"
145
+ loadingLabel="Saving…"
146
+ className="text-xs"
147
+ />
148
+ </${PopActions}>
144
149
  `;
145
150
 
146
151
  if (!ready) {
147
- return html`
148
- <div class="space-y-4">
149
- ${!embedded
150
- ? html`
151
- <${PageHeader}
152
- title="Models"
153
- actions=${html`
154
- <${ActionButton}
155
- disabled=${true}
156
- tone="primary"
157
- size="sm"
158
- idleLabel="Save changes"
159
- className="transition-all"
160
- />
161
- `}
162
- />
163
- `
164
- : null}
165
- <div class="bg-surface border border-border rounded-xl p-4">
166
- <div class="flex items-center gap-2 text-sm text-gray-400">
167
- <${LoadingSpinner} className="h-4 w-4" />
168
- Loading model settings...
169
- </div>
152
+ const loadingBody = html`
153
+ <div class="bg-surface border border-border rounded-xl p-4">
154
+ <div class="flex items-center gap-2 text-sm text-gray-400">
155
+ <${LoadingSpinner} className="h-4 w-4" />
156
+ Loading model settings...
170
157
  </div>
171
158
  </div>
172
159
  `;
160
+ if (embedded) return loadingBody;
161
+ return html`
162
+ <${PaneShell}
163
+ header=${html`<${PageHeader} title="Models" />`}
164
+ >
165
+ ${loadingBody}
166
+ </${PaneShell}>
167
+ `;
173
168
  }
174
169
 
175
- return html`
176
- <div class="space-y-4">
177
- ${!embedded
178
- ? html`<${PageHeader} title="Models" actions=${headerActions} />`
170
+ const bodyContent = html`
171
+ <!-- Configured Models -->
172
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
173
+ <h2 class="card-label">Available Models</h2>
174
+
175
+ ${configuredModelEntries.length === 0
176
+ ? html`<p class="text-xs text-gray-500">
177
+ No models configured. Add a model below.
178
+ </p>`
179
179
  : html`
180
- <div class="flex items-center justify-end gap-2">
181
- ${headerActions}
180
+ <div class="space-y-1">
181
+ ${configuredModelEntries.map(
182
+ (entry) => html`
183
+ <div
184
+ class="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-white/5"
185
+ >
186
+ <div class="flex items-center gap-2 min-w-0">
187
+ <span class="text-sm text-gray-200 truncate"
188
+ >${entry.label}</span
189
+ >
190
+ ${entry.isPrimary
191
+ ? html`<${Badge} tone="cyan">Primary</${Badge}>`
192
+ : entry.hasAuth
193
+ ? html`
194
+ <button
195
+ onclick=${() => setPrimaryModel(entry.key)}
196
+ class="text-xs px-2 py-0.5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-white/5"
197
+ >
198
+ Set primary
199
+ </button>
200
+ `
201
+ : html`<${Badge} tone="warning">Needs auth</${Badge}>`}
202
+ </div>
203
+ <button
204
+ onclick=${() => removeModel(entry.key)}
205
+ class="text-xs text-gray-600 hover:text-red-400 shrink-0 px-1"
206
+ >
207
+ Remove
208
+ </button>
209
+ </div>
210
+ `,
211
+ )}
182
212
  </div>
183
213
  `}
184
214
 
185
- <!-- Configured Models -->
186
- <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
187
- <h2 class="card-label">Available Models</h2>
215
+ <div class="space-y-2">
216
+ <${SearchableModelPicker}
217
+ options=${pickerModels}
218
+ popularModels=${popularPickerModels}
219
+ placeholder="Add model..."
220
+ onSelect=${(modelKey) => {
221
+ addModel(modelKey);
222
+ if (!primary) setPrimaryModel(modelKey);
223
+ }}
224
+ />
225
+ </div>
188
226
 
189
- ${configuredModelEntries.length === 0
190
- ? html`<p class="text-xs text-gray-500">
191
- No models configured. Add a model below.
192
- </p>`
193
- : html`
194
- <div class="space-y-1">
195
- ${configuredModelEntries.map(
196
- (entry) => html`
197
- <div
198
- class="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-white/5"
199
- >
200
- <div class="flex items-center gap-2 min-w-0">
201
- <span class="text-sm text-gray-200 truncate"
202
- >${entry.label}</span
203
- >
204
- ${entry.isPrimary
205
- ? html`<${Badge} tone="cyan">Primary</${Badge}>`
206
- : entry.hasAuth
207
- ? html`
208
- <button
209
- onclick=${() => setPrimaryModel(entry.key)}
210
- class="text-xs px-2 py-0.5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-white/5"
211
- >
212
- Set primary
213
- </button>
214
- `
215
- : html`<${Badge} tone="warning">Needs auth</${Badge}>`}
216
- </div>
217
- <button
218
- onclick=${() => removeModel(entry.key)}
219
- class="text-xs text-gray-600 hover:text-red-400 shrink-0 px-1"
220
- >
221
- Remove
222
- </button>
223
- </div>
224
- `,
225
- )}
226
- </div>
227
- `}
227
+ ${loading
228
+ ? html`<p class="text-xs text-gray-600">
229
+ Loading model catalog...
230
+ </p>`
231
+ : error
232
+ ? html`<p class="text-xs text-gray-600">${error}</p>`
233
+ : null}
234
+ </div>
228
235
 
229
- <div class="space-y-2">
230
- <${SearchableModelPicker}
231
- options=${pickerModels}
232
- popularModels=${popularPickerModels}
233
- placeholder="Add model..."
234
- onSelect=${(modelKey) => {
235
- addModel(modelKey);
236
- if (!primary) setPrimaryModel(modelKey);
237
- }}
238
- />
239
- </div>
236
+ <!-- Provider Auth -->
237
+ ${sortedProviders.length > 0
238
+ ? html`
239
+ <div class="space-y-3">
240
+ <h2 class="font-semibold text-base">
241
+ Provider Authentication
242
+ </h2>
243
+ ${sortedProviders.map(
244
+ (provider) => html`
245
+ <${ProviderAuthCard}
246
+ provider=${provider}
247
+ authProfiles=${authProfiles}
248
+ authOrder=${authOrder}
249
+ codexStatus=${codexStatus}
250
+ onEditProfile=${editProfile}
251
+ onEditAuthOrder=${editAuthOrder}
252
+ getProfileValue=${getProfileValue}
253
+ getEffectiveOrder=${getEffectiveOrder}
254
+ onRefreshCodex=${refreshCodexStatus}
255
+ />
256
+ `,
257
+ )}
258
+ </div>
259
+ `
260
+ : null}
261
+ `;
240
262
 
241
- ${loading
242
- ? html`<p class="text-xs text-gray-600">
243
- Loading model catalog...
244
- </p>`
245
- : error
246
- ? html`<p class="text-xs text-gray-600">${error}</p>`
247
- : null}
263
+ if (embedded) {
264
+ return html`
265
+ <div class="space-y-4">
266
+ <div class="flex items-center justify-end gap-2">
267
+ ${headerActions}
268
+ </div>
269
+ ${bodyContent}
248
270
  </div>
271
+ `;
272
+ }
249
273
 
250
- <!-- Provider Auth -->
251
- ${sortedProviders.length > 0
252
- ? html`
253
- <div class="space-y-3">
254
- <h2 class="font-semibold text-base">
255
- Provider Authentication
256
- </h2>
257
- ${sortedProviders.map(
258
- (provider) => html`
259
- <${ProviderAuthCard}
260
- provider=${provider}
261
- authProfiles=${authProfiles}
262
- authOrder=${authOrder}
263
- codexStatus=${codexStatus}
264
- onEditProfile=${editProfile}
265
- onEditAuthOrder=${editAuthOrder}
266
- getProfileValue=${getProfileValue}
267
- getEffectiveOrder=${getEffectiveOrder}
268
- onRefreshCodex=${refreshCodexStatus}
269
- />
270
- `,
271
- )}
272
- </div>
273
- `
274
- : null}
275
- </div>
274
+ return html`
275
+ <${PaneShell}
276
+ header=${html`<${PageHeader} title="Models" actions=${headerActions} />`}
277
+ >
278
+ ${bodyContent}
279
+ </${PaneShell}>
276
280
  `;
277
281
  };
@@ -375,7 +375,14 @@ export const ProviderAuthCard = ({
375
375
  };
376
376
  if (currentValue?.expires) cred.expires = currentValue.expires;
377
377
  onEditProfile(profileId, cred);
378
- if (hasMultipleModes && newVal && !isActive) {
378
+ const savedProfile =
379
+ authProfiles.find((p) => p.id === profileId) || null;
380
+ const isReverted =
381
+ getCredentialValue(cred) ===
382
+ getCredentialValue(savedProfile);
383
+ if (isReverted && hasMultipleModes) {
384
+ onEditAuthOrder(provider, savedOrder);
385
+ } else if (hasMultipleModes && newVal && !isActive) {
379
386
  handleSetActive(mode);
380
387
  }
381
388
  }}
@@ -92,10 +92,13 @@ export const useModels = (agentId) => {
92
92
  refresh();
93
93
  }, [agentId]);
94
94
 
95
+ const stableStringify = (obj) =>
96
+ JSON.stringify(Object.keys(obj).sort().reduce((acc, k) => { acc[k] = obj[k]; return acc; }, {}));
97
+
95
98
  const modelConfigDirty =
96
99
  primary !== savedPrimaryRef.current ||
97
- JSON.stringify(configuredModels) !==
98
- JSON.stringify(savedConfiguredRef.current);
100
+ stableStringify(configuredModels) !==
101
+ stableStringify(savedConfiguredRef.current);
99
102
 
100
103
  const authDirty = (() => {
101
104
  const hasProfileChanges = Object.entries(profileEdits).some(
@@ -155,13 +158,37 @@ export const useModels = (agentId) => {
155
158
  [updateCache],
156
159
  );
157
160
 
158
- const editProfile = useCallback((profileId, credential) => {
159
- setProfileEdits((prev) => ({ ...prev, [profileId]: credential }));
160
- }, []);
161
+ const editProfile = useCallback(
162
+ (profileId, credential) => {
163
+ const existing = authProfiles.find((p) => p.id === profileId);
164
+ if (getCredentialValue(credential) === getCredentialValue(existing)) {
165
+ setProfileEdits((prev) => {
166
+ const next = { ...prev };
167
+ delete next[profileId];
168
+ return next;
169
+ });
170
+ return;
171
+ }
172
+ setProfileEdits((prev) => ({ ...prev, [profileId]: credential }));
173
+ },
174
+ [authProfiles],
175
+ );
161
176
 
162
- const editAuthOrder = useCallback((provider, orderedIds) => {
163
- setOrderEdits((prev) => ({ ...prev, [provider]: orderedIds }));
164
- }, []);
177
+ const editAuthOrder = useCallback(
178
+ (provider, orderedIds) => {
179
+ const existing = authOrder[provider] || null;
180
+ if (JSON.stringify(orderedIds) === JSON.stringify(existing)) {
181
+ setOrderEdits((prev) => {
182
+ const next = { ...prev };
183
+ delete next[provider];
184
+ return next;
185
+ });
186
+ return;
187
+ }
188
+ setOrderEdits((prev) => ({ ...prev, [provider]: orderedIds }));
189
+ },
190
+ [authOrder],
191
+ );
165
192
 
166
193
  const getProfileValue = useCallback(
167
194
  (profileId) => {
@@ -43,7 +43,9 @@ const PairingRow = ({ pairing, onApprove, onReject }) => {
43
43
  <div class="font-medium text-sm">
44
44
  ${pairing.code || pairing.id || "Pending request"}
45
45
  </div>
46
- <span class="text-[11px] px-2 py-0.5 rounded-full border border-border text-gray-400">
46
+ <span
47
+ class="text-[11px] px-2 py-0.5 rounded-full border border-border text-gray-400"
48
+ >
47
49
  Request
48
50
  </span>
49
51
  </div>
@@ -54,14 +56,18 @@ const PairingRow = ({ pairing, onApprove, onReject }) => {
54
56
  <button
55
57
  onclick=${handleApprove}
56
58
  disabled=${!!busyAction}
57
- class="ac-btn-green text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction ? "opacity-50 cursor-not-allowed" : ""}"
59
+ class="ac-btn-green text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction
60
+ ? "opacity-50 cursor-not-allowed"
61
+ : ""}"
58
62
  >
59
63
  ${busyAction === "approve" ? "Approving..." : "Approve"}
60
64
  </button>
61
65
  <button
62
66
  onclick=${handleReject}
63
67
  disabled=${!!busyAction}
64
- class="ac-btn-secondary text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction ? "opacity-50 cursor-not-allowed" : ""}"
68
+ class="ac-btn-secondary text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction
69
+ ? "opacity-50 cursor-not-allowed"
70
+ : ""}"
65
71
  >
66
72
  ${busyAction === "reject" ? "Rejecting..." : "Reject"}
67
73
  </button>
@@ -83,15 +89,20 @@ export const WelcomePairingStep = ({
83
89
  onSkip,
84
90
  }) => {
85
91
  const channelMeta = kChannelMeta[channel] || {
86
- label: channel ? channel.charAt(0).toUpperCase() + channel.slice(1) : "Channel",
92
+ label: channel
93
+ ? channel.charAt(0).toUpperCase() + channel.slice(1)
94
+ : "Channel",
87
95
  iconSrc: "",
88
96
  };
89
97
  const channelInfo = channels?.[channel];
90
98
 
91
99
  if (!channel) {
92
100
  return html`
93
- <div class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm">
94
- Missing channel configuration. Go back and add a Telegram or Discord bot token.
101
+ <div
102
+ class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
103
+ >
104
+ Missing channel configuration. Go back and add a Telegram or Discord bot
105
+ token.
95
106
  </div>
96
107
  `;
97
108
  }
@@ -101,12 +112,16 @@ export const WelcomePairingStep = ({
101
112
  <div class="min-h-[300px] pb-6 px-6 flex flex-col">
102
113
  <div class="flex-1 flex items-center justify-center text-center">
103
114
  <div class="space-y-3 max-w-xl mx-auto">
104
- <p class="text-sm font-medium text-green-300 mb-12">🎉 Setup complete</p>
115
+ <p class="text-sm font-medium text-green-300 mb-12">
116
+ 🎉 Setup complete
117
+ </p>
105
118
  <p class="text-xs text-gray-300">
106
- Your ${channelMeta.label} channel is connected. You can switch to ${channelMeta.label} and start using your agent now.
119
+ Your ${channelMeta.label} channel is connected. You can switch to
120
+ ${channelMeta.label} and start using your agent now.
107
121
  </p>
108
122
  <p class="text-xs text-gray-500 font-normal opacity-85">
109
- Continue to the dashboard to explore extras like Google Workspace and additional integrations.
123
+ Continue to the dashboard to explore extras like Google Workspace
124
+ and additional integrations.
110
125
  </p>
111
126
  </div>
112
127
  </div>
@@ -124,60 +139,74 @@ export const WelcomePairingStep = ({
124
139
  <div class="min-h-[300px] pb-6 flex flex-col gap-3">
125
140
  <div class="flex items-center justify-end gap-2">
126
141
  <${Badge} tone="warning"
127
- >${loading
128
- ? "Checking..."
129
- : pairings.length > 0
130
- ? "Pairing request detected"
131
- : "Awaiting pairing"}</${Badge}
142
+ >${
143
+ loading
144
+ ? "Checking..."
145
+ : pairings.length > 0
146
+ ? "Pairing request detected"
147
+ : "Awaiting pairing"
148
+ }</${Badge}
132
149
  >
133
150
  </div>
134
151
 
135
- ${pairings.length > 0
136
- ? html`<div class="flex-1 flex items-center">
137
- <div class="w-full">
138
- ${pairings.map(
139
- (pairing) =>
140
- html`<${PairingRow}
141
- key=${pairing.id}
142
- pairing=${pairing}
143
- onApprove=${onApprove}
144
- onReject=${onReject}
145
- />`,
146
- )}
147
- </div>
148
- </div>`
149
- : html`<div class="flex-1 flex items-center justify-center text-center py-4">
150
- <div class="space-y-4">
151
- ${channelMeta.iconSrc
152
- ? html`<img
153
- src=${channelMeta.iconSrc}
154
- alt=${channelMeta.label}
155
- class="w-8 h-8 mx-auto rounded-md"
156
- />`
157
- : null}
158
- <p class="text-gray-300 text-sm">
159
- Send a message to your ${channelMeta.label} bot
160
- </p>
161
- <p class="text-gray-600 text-xs">
162
- The pairing request will appear here in 5-10 seconds
163
- </p>
164
- </div>
165
- </div>`}
152
+ ${
153
+ pairings.length > 0
154
+ ? html`<div class="flex-1 flex items-center">
155
+ <div class="w-full">
156
+ ${pairings.map(
157
+ (pairing) =>
158
+ html`<${PairingRow}
159
+ key=${pairing.id}
160
+ pairing=${pairing}
161
+ onApprove=${onApprove}
162
+ onReject=${onReject}
163
+ />`,
164
+ )}
165
+ </div>
166
+ </div>`
167
+ : html`<div
168
+ class="flex-1 flex items-center justify-center text-center py-4"
169
+ >
170
+ <div class="space-y-4">
171
+ ${channelMeta.iconSrc
172
+ ? html`<img
173
+ src=${channelMeta.iconSrc}
174
+ alt=${channelMeta.label}
175
+ class="w-8 h-8 mx-auto rounded-md"
176
+ />`
177
+ : null}
178
+ <p class="text-gray-300 text-sm">
179
+ Send a message to your ${channelMeta.label} bot
180
+ </p>
181
+ <p class="text-gray-600 text-xs">
182
+ The pairing request will appear here in 5-10 seconds
183
+ </p>
184
+ </div>
185
+ </div>`
186
+ }
166
187
 
167
- ${error
168
- ? html`<div class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm">
169
- ${error}
170
- </div>`
171
- : null}
172
- <div class="pt-3 border-t border-border text-center">
173
- <button
174
- type="button"
175
- onclick=${onSkip}
176
- class="ac-tip-link text-xs font-medium"
177
- >
178
- Skip pairing for now
179
- </button>
180
- </div>
188
+ ${
189
+ error
190
+ ? html`<div
191
+ class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
192
+ >
193
+ ${error}
194
+ </div>`
195
+ : null
196
+ }
197
+ ${
198
+ pairings.length === 0
199
+ ? html`<div class="pt-3 text-center">
200
+ <button
201
+ type="button"
202
+ onclick=${onSkip}
203
+ class="ac-tip-link text-xs font-medium"
204
+ >
205
+ Skip pairing for now
206
+ </button>
207
+ </div>`
208
+ : null
209
+ }
181
210
  </div>
182
211
  `;
183
212
  };
@@ -0,0 +1,27 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+
4
+ const html = htm.bind(h);
5
+
6
+ /**
7
+ * Shared layout shell for pages that need a fixed header with a
8
+ * separately scrollable body. The header stays pinned at the top
9
+ * while body content scrolls underneath.
10
+ *
11
+ * @param {preact.ComponentChildren} props.header Content rendered in the fixed header area.
12
+ * @param {preact.ComponentChildren} props.children Content rendered in the scrollable body.
13
+ */
14
+ export const PaneShell = ({ header, children }) => html`
15
+ <div class="ac-pane-shell">
16
+ <div class="ac-pane-header">
17
+ <div class="ac-pane-header-content">
18
+ ${header}
19
+ </div>
20
+ </div>
21
+ <div class="ac-pane-body">
22
+ <div class="ac-pane-body-content">
23
+ ${children}
24
+ </div>
25
+ </div>
26
+ </div>
27
+ `;