@chrysb/alphaclaw 0.6.2-beta.6 → 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.
@@ -8,8 +8,13 @@ import {
8
8
  import htm from "https://esm.sh/htm";
9
9
  import { SegmentedControl } from "../segmented-control.js";
10
10
  import { Tooltip } from "../tooltip.js";
11
- import { formatCost, formatTokenCount } from "./cron-helpers.js";
12
- import { formatCronScheduleLabel } from "./cron-helpers.js";
11
+ import {
12
+ formatCost,
13
+ formatCronScheduleLabel,
14
+ formatTokenCount,
15
+ getCronRunEstimatedCost,
16
+ getCronRunTotalTokens,
17
+ } from "./cron-helpers.js";
13
18
  import { readUiSettings, updateUiSettings } from "../../lib/ui-settings.js";
14
19
  import {
15
20
  classifyRepeatingJobs,
@@ -104,40 +109,6 @@ const formatUpcomingTime = (timestampMs) => {
104
109
  });
105
110
  };
106
111
 
107
- const getRunTotalTokens = (entry = {}) => {
108
- const usage = entry?.usage || {};
109
- const candidates = [
110
- usage?.total_tokens,
111
- usage?.totalTokens,
112
- entry?.total_tokens,
113
- entry?.totalTokens,
114
- ];
115
- for (const candidate of candidates) {
116
- const numericValue = Number(candidate);
117
- if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
118
- }
119
- return 0;
120
- };
121
-
122
- const getRunEstimatedCost = (entry = {}) => {
123
- const usage = entry?.usage || {};
124
- const candidates = [
125
- entry?.estimatedCost,
126
- entry?.estimated_cost,
127
- usage?.estimatedCost,
128
- usage?.estimated_cost,
129
- usage?.totalCost,
130
- usage?.total_cost,
131
- usage?.costUsd,
132
- usage?.cost,
133
- ];
134
- for (const candidate of candidates) {
135
- const numericValue = Number(candidate);
136
- if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
137
- }
138
- return null;
139
- };
140
-
141
112
  const buildRunSummaryByJobId = ({
142
113
  runsByJobId = {},
143
114
  nowMs = Date.now(),
@@ -158,11 +129,11 @@ const buildRunSummaryByJobId = ({
158
129
  });
159
130
  const runCount = recentEntries.length;
160
131
  const totalTokens = recentEntries.reduce(
161
- (sum, entry) => sum + Number(getRunTotalTokens(entry) || 0),
132
+ (sum, entry) => sum + Number(getCronRunTotalTokens(entry) || 0),
162
133
  0,
163
134
  );
164
135
  const totalCost = recentEntries.reduce((sum, entry) => {
165
- const cost = getRunEstimatedCost(entry);
136
+ const cost = getCronRunEstimatedCost(entry);
166
137
  return sum + Number(cost == null ? 0 : cost);
167
138
  }, 0);
168
139
  accumulator[String(jobId || "")] = {
@@ -274,8 +245,8 @@ const buildJobTooltipText = ({
274
245
  const runCount7d = Number(runSummary7d?.runCount || 0);
275
246
  const avgTokensPerRun7d = Number(runSummary7d?.avgTokensPerRun || 0);
276
247
  const avgCostPerRun7d = Number(runSummary7d?.avgCostPerRun || 0);
277
- const slotRunTokens = getRunTotalTokens(slotRun || {});
278
- const slotRunCost = getRunEstimatedCost(slotRun || {});
248
+ const slotRunTokens = getCronRunTotalTokens(slotRun || {});
249
+ const slotRunCost = getCronRunEstimatedCost(slotRun || {});
279
250
  const slotRunStatus = String(slotRun?.status || "")
280
251
  .trim()
281
252
  .toLowerCase();
@@ -428,7 +399,7 @@ export const CronCalendar = ({
428
399
  if (!job || job.enabled === false) return;
429
400
  const isPastSlot = Number(slot?.scheduledAtMs || 0) <= nowMs;
430
401
  if (isPastSlot) {
431
- const slotRunTokens = getRunTotalTokens(runBySlotKey[slot.key] || {});
402
+ const slotRunTokens = getCronRunTotalTokens(runBySlotKey[slot.key] || {});
432
403
  if (slotRunTokens > 0) values.push(slotRunTokens);
433
404
  return;
434
405
  }
@@ -460,7 +431,7 @@ export const CronCalendar = ({
460
431
  const enabled = job?.enabled !== false;
461
432
  const isPastSlot = Number(slot?.scheduledAtMs || 0) <= nowMs;
462
433
  if (isPastSlot) {
463
- const slotRunTokens = getRunTotalTokens(
434
+ const slotRunTokens = getCronRunTotalTokens(
464
435
  runBySlotKey[String(slot?.key || "")] || {},
465
436
  );
466
437
  return classifyTokenTier({
@@ -276,6 +276,54 @@ export const getCronJobHealthClassName = (health = "") => {
276
276
 
277
277
  export const formatTokenCount = (value) => formatInteger(Number(value || 0));
278
278
  export const formatCost = (value) => formatUsd(Number(value || 0));
279
+ export const getCronRunTotalTokens = (entry = {}) => {
280
+ const usage = entry?.usage || {};
281
+ const componentCandidates = [
282
+ usage?.input_tokens,
283
+ usage?.inputTokens,
284
+ usage?.output_tokens,
285
+ usage?.outputTokens,
286
+ usage?.cache_read_tokens,
287
+ usage?.cacheReadTokens,
288
+ usage?.cache_write_tokens,
289
+ usage?.cacheWriteTokens,
290
+ ];
291
+ const componentTotal = componentCandidates.reduce((sum, candidate) => {
292
+ const numericValue = Number(candidate);
293
+ if (!Number.isFinite(numericValue) || numericValue < 0) return sum;
294
+ return sum + numericValue;
295
+ }, 0);
296
+ if (componentTotal > 0) return componentTotal;
297
+ const totalCandidates = [
298
+ usage?.total_tokens,
299
+ usage?.totalTokens,
300
+ entry?.total_tokens,
301
+ entry?.totalTokens,
302
+ ];
303
+ for (const candidate of totalCandidates) {
304
+ const numericValue = Number(candidate);
305
+ if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
306
+ }
307
+ return 0;
308
+ };
309
+ export const getCronRunEstimatedCost = (entry = {}) => {
310
+ const usage = entry?.usage || {};
311
+ const candidates = [
312
+ entry?.estimatedCost,
313
+ entry?.estimated_cost,
314
+ usage?.estimatedCost,
315
+ usage?.estimated_cost,
316
+ usage?.totalCost,
317
+ usage?.total_cost,
318
+ usage?.costUsd,
319
+ usage?.cost,
320
+ ];
321
+ for (const candidate of candidates) {
322
+ const numericValue = Number(candidate);
323
+ if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
324
+ }
325
+ return null;
326
+ };
279
327
  const hasHeartbeatOnlySummary = (job = {}) => {
280
328
  const state = job?.state || {};
281
329
  try {
@@ -3,7 +3,12 @@ import { useMemo, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { SegmentedControl } from "../segmented-control.js";
5
5
  import { Badge } from "../badge.js";
6
- import { formatCost, formatTokenCount } from "./cron-helpers.js";
6
+ import {
7
+ formatCost,
8
+ formatTokenCount,
9
+ getCronRunEstimatedCost,
10
+ getCronRunTotalTokens,
11
+ } from "./cron-helpers.js";
7
12
 
8
13
  const html = htm.bind(h);
9
14
 
@@ -35,40 +40,6 @@ const formatRunCountLabel = (count = 0) => {
35
40
  return `${countLabel} ${safeCount === 1 ? "run" : "runs"}`;
36
41
  };
37
42
 
38
- const getRunTotalTokens = (entry = {}) => {
39
- const usage = entry?.usage || {};
40
- const candidates = [
41
- usage?.total_tokens,
42
- usage?.totalTokens,
43
- entry?.total_tokens,
44
- entry?.totalTokens,
45
- ];
46
- for (const candidate of candidates) {
47
- const numericValue = Number(candidate);
48
- if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
49
- }
50
- return 0;
51
- };
52
-
53
- const getRunEstimatedCost = (entry = {}) => {
54
- const usage = entry?.usage || {};
55
- const candidates = [
56
- entry?.estimatedCost,
57
- entry?.estimated_cost,
58
- usage?.estimatedCost,
59
- usage?.estimated_cost,
60
- usage?.totalCost,
61
- usage?.total_cost,
62
- usage?.costUsd,
63
- usage?.cost,
64
- ];
65
- for (const candidate of candidates) {
66
- const numericValue = Number(candidate);
67
- if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
68
- }
69
- return null;
70
- };
71
-
72
43
  const readDeliveryMode = (job = null) =>
73
44
  String(job?.delivery?.mode || job?.deliveryMode || "none")
74
45
  .trim()
@@ -137,10 +108,8 @@ const buildInsightMetrics = ({
137
108
  return;
138
109
  }
139
110
  metricsByJobId[jobId].runCount += 1;
140
- metricsByJobId[jobId].totalTokens += Number(
141
- getRunTotalTokens(entry) || 0,
142
- );
143
- const estimatedCost = getRunEstimatedCost(entry);
111
+ metricsByJobId[jobId].totalTokens += Number(getCronRunTotalTokens(entry) || 0);
112
+ const estimatedCost = getCronRunEstimatedCost(entry);
144
113
  if (estimatedCost != null) {
145
114
  metricsByJobId[jobId].hasCostData = true;
146
115
  metricsByJobId[jobId].totalCost += Number(estimatedCost || 0);
@@ -5,7 +5,12 @@ import {
5
5
  formatDurationCompactMs,
6
6
  formatLocaleDateTimeWithTodayTime,
7
7
  } from "../../lib/format.js";
8
- import { formatCost, formatTokenCount } from "./cron-helpers.js";
8
+ import {
9
+ formatCost,
10
+ formatTokenCount,
11
+ getCronRunEstimatedCost,
12
+ getCronRunTotalTokens,
13
+ } from "./cron-helpers.js";
9
14
 
10
15
  const html = htm.bind(h);
11
16
  const runStatusClassName = (status = "") => {
@@ -19,10 +24,6 @@ const runStatusClassName = (status = "") => {
19
24
  };
20
25
  const runDeliveryLabel = (run) =>
21
26
  String(run?.deliveryStatus || "not-requested");
22
- const getRunEstimatedCost = (runEntry = {}) => {
23
- const parsed = Number(runEntry?.estimatedCost);
24
- return Number.isFinite(parsed) ? parsed : null;
25
- };
26
27
  const formatOverviewTimestamp = (timestampMs) =>
27
28
  formatLocaleDateTimeWithTodayTime(timestampMs, {
28
29
  fallback: "—",
@@ -100,10 +101,8 @@ const renderEntryRow = ({
100
101
  const runOutputTokens = Number(
101
102
  runUsage?.output_tokens ?? runUsage?.outputTokens ?? 0,
102
103
  );
103
- const runTokens = Number(
104
- runUsage?.total_tokens ?? runUsage?.totalTokens ?? 0,
105
- );
106
- const runEstimatedCost = getRunEstimatedCost(runEntry);
104
+ const runTokens = getCronRunTotalTokens(runEntry);
105
+ const runEstimatedCost = getCronRunEstimatedCost(runEntry);
107
106
  const runTitle = String(runEntry?.jobName || "").trim();
108
107
  const hasRunTitle = runTitle.length > 0;
109
108
  const isDetail = variant === "detail";
@@ -2,7 +2,7 @@ import { h } from "https://esm.sh/preact";
2
2
  import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { SegmentedControl } from "../segmented-control.js";
5
- import { formatCost } from "./cron-helpers.js";
5
+ import { formatCost, getCronRunEstimatedCost } from "./cron-helpers.js";
6
6
 
7
7
  const html = htm.bind(h);
8
8
 
@@ -64,25 +64,6 @@ const getBucketConfig = (range = kRange7d) => {
64
64
  };
65
65
  };
66
66
 
67
- const getEstimatedCostForEntry = (entry = {}) => {
68
- const usage = entry?.usage || {};
69
- const candidates = [
70
- entry?.estimatedCost,
71
- entry?.estimated_cost,
72
- usage?.estimatedCost,
73
- usage?.estimated_cost,
74
- usage?.totalCost,
75
- usage?.total_cost,
76
- usage?.costUsd,
77
- usage?.cost,
78
- ];
79
- for (const candidate of candidates) {
80
- const numericValue = Number(candidate);
81
- if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
82
- }
83
- return null;
84
- };
85
-
86
67
  const buildTrendData = ({ bulkRunsByJobId = {}, nowMs = Date.now(), range = kRange7d } = {}) => {
87
68
  const config = getBucketConfig(range);
88
69
  const safeNowMs = Number.isFinite(Number(nowMs)) ? Number(nowMs) : Date.now();
@@ -129,7 +110,7 @@ const buildTrendData = ({ bulkRunsByJobId = {}, nowMs = Date.now(), range = kRan
129
110
  if (!Number.isFinite(Number(bucketIndex))) return;
130
111
  if (bucketIndex < 0 || bucketIndex >= config.bucketCount) return;
131
112
  points[bucketIndex][status] += 1;
132
- const estimatedCost = getEstimatedCostForEntry(entry);
113
+ const estimatedCost = getCronRunEstimatedCost(entry);
133
114
  if (estimatedCost != null) {
134
115
  points[bucketIndex].totalCost += estimatedCost;
135
116
  points[bucketIndex].costCount += 1;
@@ -305,6 +286,7 @@ export const CronRunsTrendCard = ({
305
286
  },
306
287
  plugins: {
307
288
  legend: {
289
+ position: "bottom",
308
290
  labels: {
309
291
  color: "rgba(209,213,219,1)",
310
292
  boxWidth: 10,
@@ -339,7 +321,7 @@ export const CronRunsTrendCard = ({
339
321
  return html`
340
322
  <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
341
323
  <div class="flex items-center justify-between gap-2">
342
- <h3 class="card-label cron-calendar-title">Run Outcome Trend</h3>
324
+ <h3 class="card-label cron-calendar-title">Run Outcomes</h3>
343
325
  <${SegmentedControl}
344
326
  options=${kRanges}
345
327
  value=${range}
@@ -1,11 +1,18 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useState, useEffect, useCallback, useRef } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ useState,
4
+ useEffect,
5
+ useCallback,
6
+ useRef,
7
+ } from "https://esm.sh/preact/hooks";
3
8
  import htm from "https://esm.sh/htm";
4
9
  import { fetchEnvVars, saveEnvVars } from "../lib/api.js";
5
10
  import { showToast } from "./toast.js";
6
11
  import { SecretInput } from "./secret-input.js";
7
12
  import { PageHeader } from "./page-header.js";
8
13
  import { ActionButton } from "./action-button.js";
14
+ import { PopActions } from "./pop-actions.js";
15
+ import { PaneShell } from "./pane-shell.js";
9
16
  import {
10
17
  Brain2LineIcon,
11
18
  ChatVoiceLineIcon,
@@ -44,7 +51,11 @@ const kFeatureIconByName = {
44
51
  label: "Speech to text",
45
52
  },
46
53
  };
47
- const normalizeEnvVarKey = (raw) => raw.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
54
+ const normalizeEnvVarKey = (raw) =>
55
+ raw
56
+ .trim()
57
+ .toUpperCase()
58
+ .replace(/[^A-Z0-9_]/g, "_");
48
59
  const kManagedChannelTokenPattern =
49
60
  /^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/;
50
61
  const stripSurroundingQuotes = (raw) => {
@@ -59,7 +70,11 @@ const stripSurroundingQuotes = (raw) => {
59
70
  return value;
60
71
  };
61
72
  const isManagedChannelTokenKey = (key = "") =>
62
- kManagedChannelTokenPattern.test(String(key || "").trim().toUpperCase());
73
+ kManagedChannelTokenPattern.test(
74
+ String(key || "")
75
+ .trim()
76
+ .toUpperCase(),
77
+ );
63
78
  const getVarsSignature = (items) =>
64
79
  JSON.stringify(
65
80
  (items || [])
@@ -85,19 +100,119 @@ const sortCustomVarsAlphabetically = (items) => {
85
100
  };
86
101
 
87
102
  const kHintByKey = {
88
- ANTHROPIC_API_KEY: html`from <a href="https://console.anthropic.com" target="_blank" class="hover:underline" style="color: var(--accent-link)">console.anthropic.com</a>`,
89
- ANTHROPIC_TOKEN: html`from <code class="text-xs bg-black/30 px-1 rounded">claude setup-token</code>`,
90
- OPENAI_API_KEY: html`from <a href="https://platform.openai.com" target="_blank" class="hover:underline" style="color: var(--accent-link)">platform.openai.com</a>`,
91
- GEMINI_API_KEY: html`from <a href="https://aistudio.google.com" target="_blank" class="hover:underline" style="color: var(--accent-link)">aistudio.google.com</a>`,
92
- ELEVENLABS_API_KEY: html`from <a href="https://elevenlabs.io" target="_blank" class="hover:underline" style="color: var(--accent-link)">elevenlabs.io</a> · <code class="text-xs bg-black/30 px-1 rounded">XI_API_KEY</code> also supported`,
93
- GITHUB_WORKSPACE_REPO: html`use <code class="text-xs bg-black/30 px-1 rounded">owner/repo</code> or <code class="text-xs bg-black/30 px-1 rounded">https://github.com/owner/repo</code>`,
94
- TELEGRAM_BOT_TOKEN: html`from <a href="https://t.me/BotFather" target="_blank" class="hover:underline" style="color: var(--accent-link)">@BotFather</a> · <a href="https://docs.openclaw.ai/channels/telegram" target="_blank" class="hover:underline" style="color: var(--accent-link)">full guide</a>`,
95
- DISCORD_BOT_TOKEN: html`from <a href="https://discord.com/developers/applications" target="_blank" class="hover:underline" style="color: var(--accent-link)">developer portal</a> · <a href="https://docs.openclaw.ai/channels/discord" target="_blank" class="hover:underline" style="color: var(--accent-link)">full guide</a>`,
96
- MISTRAL_API_KEY: html`from <a href="https://console.mistral.ai" target="_blank" class="hover:underline" style="color: var(--accent-link)">console.mistral.ai</a>`,
97
- VOYAGE_API_KEY: html`from <a href="https://dash.voyageai.com" target="_blank" class="hover:underline" style="color: var(--accent-link)">dash.voyageai.com</a>`,
98
- GROQ_API_KEY: html`from <a href="https://console.groq.com" target="_blank" class="hover:underline" style="color: var(--accent-link)">console.groq.com</a>`,
99
- DEEPGRAM_API_KEY: html`from <a href="https://console.deepgram.com" target="_blank" class="hover:underline" style="color: var(--accent-link)">console.deepgram.com</a>`,
100
- BRAVE_API_KEY: html`from <a href="https://brave.com/search/api/" target="_blank" class="hover:underline" style="color: var(--accent-link)">brave.com/search/api</a> — free tier available`,
103
+ ANTHROPIC_API_KEY: html`from${" "}
104
+ <a
105
+ href="https://console.anthropic.com"
106
+ target="_blank"
107
+ class="hover:underline"
108
+ style="color: var(--accent-link)"
109
+ >console.anthropic.com</a
110
+ >`,
111
+ ANTHROPIC_TOKEN: html`from
112
+ <code class="text-xs bg-black/30 px-1 rounded">claude setup-token</code>`,
113
+ OPENAI_API_KEY: html`from${" "}
114
+ <a
115
+ href="https://platform.openai.com"
116
+ target="_blank"
117
+ class="hover:underline"
118
+ style="color: var(--accent-link)"
119
+ >platform.openai.com</a
120
+ >`,
121
+ GEMINI_API_KEY: html`from${" "}
122
+ <a
123
+ href="https://aistudio.google.com"
124
+ target="_blank"
125
+ class="hover:underline"
126
+ style="color: var(--accent-link)"
127
+ >aistudio.google.com</a
128
+ >`,
129
+ ELEVENLABS_API_KEY: html`from${" "}
130
+ <a
131
+ href="https://elevenlabs.io"
132
+ target="_blank"
133
+ class="hover:underline"
134
+ style="color: var(--accent-link)"
135
+ >elevenlabs.io</a
136
+ >${" "} · ${" "}
137
+ <code class="text-xs bg-black/30 px-1 rounded">XI_API_KEY</code> also
138
+ supported`,
139
+ GITHUB_WORKSPACE_REPO: html`use
140
+ <code class="text-xs bg-black/30 px-1 rounded">owner/repo</code> or
141
+ <code class="text-xs bg-black/30 px-1 rounded"
142
+ >https://github.com/owner/repo</code
143
+ >`,
144
+ TELEGRAM_BOT_TOKEN: html`from${" "}
145
+ <a
146
+ href="https://t.me/BotFather"
147
+ target="_blank"
148
+ class="hover:underline"
149
+ style="color: var(--accent-link)"
150
+ >@BotFather</a
151
+ >
152
+ ·
153
+ <a
154
+ href="https://docs.openclaw.ai/channels/telegram"
155
+ target="_blank"
156
+ class="hover:underline"
157
+ style="color: var(--accent-link)"
158
+ >full guide</a
159
+ >`,
160
+ DISCORD_BOT_TOKEN: html`from${" "}
161
+ <a
162
+ href="https://discord.com/developers/applications"
163
+ target="_blank"
164
+ class="hover:underline"
165
+ style="color: var(--accent-link)"
166
+ >developer portal</a
167
+ >
168
+ ·
169
+ <a
170
+ href="https://docs.openclaw.ai/channels/discord"
171
+ target="_blank"
172
+ class="hover:underline"
173
+ style="color: var(--accent-link)"
174
+ >full guide</a
175
+ >`,
176
+ MISTRAL_API_KEY: html`from${" "}
177
+ <a
178
+ href="https://console.mistral.ai"
179
+ target="_blank"
180
+ class="hover:underline"
181
+ style="color: var(--accent-link)"
182
+ >console.mistral.ai</a
183
+ >`,
184
+ VOYAGE_API_KEY: html`from${" "}
185
+ <a
186
+ href="https://dash.voyageai.com"
187
+ target="_blank"
188
+ class="hover:underline"
189
+ style="color: var(--accent-link)"
190
+ >dash.voyageai.com</a
191
+ >`,
192
+ GROQ_API_KEY: html`from${" "}
193
+ <a
194
+ href="https://console.groq.com"
195
+ target="_blank"
196
+ class="hover:underline"
197
+ style="color: var(--accent-link)"
198
+ >console.groq.com</a
199
+ >`,
200
+ DEEPGRAM_API_KEY: html`from${" "}
201
+ <a
202
+ href="https://console.deepgram.com"
203
+ target="_blank"
204
+ class="hover:underline"
205
+ style="color: var(--accent-link)"
206
+ >console.deepgram.com</a
207
+ >`,
208
+ BRAVE_API_KEY: html`from${" "}
209
+ <a
210
+ href="https://brave.com/search/api/"
211
+ target="_blank"
212
+ class="hover:underline"
213
+ style="color: var(--accent-link)"
214
+ >brave.com/search/api</a
215
+ >${" "} — free tier available`,
101
216
  };
102
217
 
103
218
  const getHintContent = (envVar) => kHintByKey[envVar.key] || envVar.hint || "";
@@ -157,7 +272,8 @@ const EnvRow = ({ envVar, onChange, onDelete, disabled }) => {
157
272
  ? html`
158
273
  <div class="flex items-center gap-2 mt-1 pl-3.5">
159
274
  ${featureIcons.map(
160
- (feature) => html`<${FeatureIcon} key=${feature} feature=${feature} />`,
275
+ (feature) =>
276
+ html`<${FeatureIcon} key=${feature} feature=${feature} />`,
161
277
  )}
162
278
  </div>
163
279
  `
@@ -183,9 +299,7 @@ const EnvRow = ({ envVar, onChange, onDelete, disabled }) => {
183
299
  </button>`
184
300
  : null}
185
301
  </div>
186
- ${hint
187
- ? html`<p class="text-xs text-gray-600 mt-1">${hint}</p>`
188
- : null}
302
+ ${hint ? html`<p class="text-xs text-gray-600 mt-1">${hint}</p>` : null}
189
303
  </div>
190
304
  </div>
191
305
  `;
@@ -230,7 +344,9 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
230
344
 
231
345
  const handleDelete = (key) => {
232
346
  setVars((prev) => prev.filter((v) => v.key !== key));
233
- setPendingCustomKeys((prev) => prev.filter((pendingKey) => pendingKey !== key));
347
+ setPendingCustomKeys((prev) =>
348
+ prev.filter((pendingKey) => pendingKey !== key),
349
+ );
234
350
  };
235
351
 
236
352
  const handleSave = async () => {
@@ -348,7 +464,10 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
348
464
  );
349
465
  }
350
466
  if (added) {
351
- showToast(`Added ${added} variable${added !== 1 ? "s" : ""}`, "success");
467
+ showToast(
468
+ `Added ${added} variable${added !== 1 ? "s" : ""}`,
469
+ "success",
470
+ );
352
471
  }
353
472
  return;
354
473
  }
@@ -410,7 +529,9 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
410
529
  const nonPending = grouped.custom
411
530
  .filter((item) => !pending.has(item.key))
412
531
  .sort((a, b) => String(a?.key || "").localeCompare(String(b?.key || "")));
413
- const pendingAtBottom = grouped.custom.filter((item) => pending.has(item.key));
532
+ const pendingAtBottom = grouped.custom.filter((item) =>
533
+ pending.has(item.key),
534
+ );
414
535
  grouped.custom = [...nonPending, ...pendingAtBottom];
415
536
  }
416
537
  const aiSplit = splitAiVars(grouped.ai || []);
@@ -454,7 +575,9 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
454
575
  `
455
576
  : null}
456
577
  ${expanded
457
- ? html`<div class="divide-y divide-border border-t border-border">${renderEnvRows(hidden)}</div>`
578
+ ? html`<div class="divide-y divide-border border-t border-border">
579
+ ${renderEnvRows(hidden)}
580
+ </div>`
458
581
  : null}
459
582
  </div>
460
583
  `;
@@ -470,33 +593,52 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
470
593
  };
471
594
 
472
595
  return html`
473
- <div class="space-y-4">
474
- <${PageHeader}
475
- title="Envars"
476
- actions=${html`
477
- <${ActionButton}
478
- onClick=${handleSave}
479
- disabled=${!dirty || saving}
480
- loading=${saving}
481
- tone="primary"
482
- size="sm"
483
- idleLabel="Save changes"
484
- loadingLabel="Saving..."
485
- className="transition-all"
486
- />
487
- `}
488
- />
489
-
596
+ <${PaneShell}
597
+ header=${html`
598
+ <${PageHeader}
599
+ title="Envars"
600
+ actions=${html`
601
+ <${PopActions} visible=${dirty}>
602
+ <${ActionButton}
603
+ onClick=${load}
604
+ disabled=${saving}
605
+ tone="secondary"
606
+ size="sm"
607
+ idleLabel="Cancel"
608
+ className="text-xs"
609
+ />
610
+ <${ActionButton}
611
+ onClick=${handleSave}
612
+ disabled=${saving}
613
+ loading=${saving}
614
+ loadingMode="inline"
615
+ tone="primary"
616
+ size="sm"
617
+ idleLabel="Save changes"
618
+ loadingLabel="Saving…"
619
+ className="text-xs"
620
+ />
621
+ </${PopActions}>
622
+ `}
623
+ />
624
+ `}
625
+ >
490
626
  ${kGroupOrder
491
627
  .filter((g) => grouped[g]?.length)
492
628
  .map((g) => renderGroupCard(g))}
493
629
 
494
- <div class="bg-surface border border-border rounded-xl overflow-hidden">
630
+ <div
631
+ class="bg-surface border border-border rounded-xl overflow-hidden"
632
+ >
495
633
  <div class="flex items-center justify-between px-4 pt-3 pb-2">
496
634
  <h3 class="card-label text-xs">Add Variable</h3>
497
- <span class="text-xs" style="color: var(--text-dim)">Paste KEY=VALUE or multiple lines</span>
635
+ <span class="text-xs" style="color: var(--text-dim)"
636
+ >Paste KEY=VALUE or multiple lines</span
637
+ >
498
638
  </div>
499
- <div class="flex items-start gap-4 px-4 py-3 border-t border-border">
639
+ <div
640
+ class="flex items-start gap-4 px-4 py-3 border-t border-border"
641
+ >
500
642
  <div class="shrink-0" style="width: 200px">
501
643
  <input
502
644
  type="text"
@@ -527,7 +669,6 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
527
669
  </div>
528
670
  </div>
529
671
  </div>
530
-
531
- </div>
672
+ </${PaneShell}>
532
673
  `;
533
674
  };