@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
@@ -1,4 +1,5 @@
1
1
  import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
2
3
  import htm from "https://esm.sh/htm";
3
4
  import { ActionButton } from "../action-button.js";
4
5
  import { PageHeader } from "../page-header.js";
@@ -11,94 +12,185 @@ import { useCronTab } from "./use-cron-tab.js";
11
12
  const html = htm.bind(h);
12
13
 
13
14
  export const CronTab = ({ jobId = "", onSetLocation = () => {} }) => {
14
- const { refs, state, actions } = useCronTab({ jobId, onSetLocation });
15
+ const { state, actions } = useCronTab({ jobId, onSetLocation });
16
+ const [showJobSelector, setShowJobSelector] = useState(false);
17
+ const selectorShellRef = useRef(null);
15
18
  const isAllJobsSelected = state.selectedRouteKey === kAllCronJobsRouteKey;
16
19
  const noJobs = state.jobs.length === 0;
20
+ const selectedJob = state.selectedJob;
21
+ const selectedJobLabel = useMemo(() => {
22
+ if (isAllJobsSelected) return "All jobs";
23
+ const selectedJob = state.jobs.find(
24
+ (job) => String(job?.id || "") === String(state.selectedRouteKey || ""),
25
+ );
26
+ return String(selectedJob?.name || selectedJob?.id || "All jobs");
27
+ }, [isAllJobsSelected, state.jobs, state.selectedRouteKey]);
28
+ const hasUnsavedDetailChanges = useMemo(() => {
29
+ if (isAllJobsSelected || !selectedJob) return false;
30
+ const sessionTarget = String(
31
+ state.routingDraft?.sessionTarget || selectedJob?.sessionTarget || "main",
32
+ );
33
+ const wakeMode = String(
34
+ state.routingDraft?.wakeMode || selectedJob?.wakeMode || "now",
35
+ );
36
+ const deliveryMode = String(
37
+ state.routingDraft?.deliveryMode || selectedJob?.delivery?.mode || "none",
38
+ );
39
+ const currentSessionTarget = String(selectedJob?.sessionTarget || "main");
40
+ const currentWakeMode = String(selectedJob?.wakeMode || "now");
41
+ const currentDeliveryMode = String(selectedJob?.delivery?.mode || "none");
42
+ const isRoutingDirty =
43
+ sessionTarget !== currentSessionTarget ||
44
+ wakeMode !== currentWakeMode ||
45
+ deliveryMode !== currentDeliveryMode;
46
+ const isPromptDirty = state.promptValue !== state.savedPromptValue;
47
+ return isRoutingDirty || isPromptDirty;
48
+ }, [
49
+ isAllJobsSelected,
50
+ selectedJob,
51
+ state.promptValue,
52
+ state.routingDraft?.deliveryMode,
53
+ state.routingDraft?.sessionTarget,
54
+ state.routingDraft?.wakeMode,
55
+ state.savedPromptValue,
56
+ ]);
57
+
58
+ useEffect(() => {
59
+ if (!showJobSelector) return () => {};
60
+ const handlePointerDown = (event) => {
61
+ if (selectorShellRef.current?.contains(event.target)) return;
62
+ setShowJobSelector(false);
63
+ };
64
+ const handleKeyDown = (event) => {
65
+ if (event.key !== "Escape") return;
66
+ setShowJobSelector(false);
67
+ };
68
+ window.addEventListener("pointerdown", handlePointerDown);
69
+ window.addEventListener("keydown", handleKeyDown);
70
+ return () => {
71
+ window.removeEventListener("pointerdown", handlePointerDown);
72
+ window.removeEventListener("keydown", handleKeyDown);
73
+ };
74
+ }, [showJobSelector]);
75
+
76
+ const handleSelectAllJobs = () => {
77
+ actions.selectAllJobs();
78
+ setShowJobSelector(false);
79
+ };
80
+
81
+ const handleSelectJob = (nextJobId) => {
82
+ actions.selectJob(nextJobId);
83
+ setShowJobSelector(false);
84
+ };
17
85
 
18
86
  return html`
19
87
  <div class="cron-tab-shell">
20
88
  <div class="cron-tab-header">
21
- <${PageHeader}
22
- title="Cron Jobs"
23
- actions=${html`
24
- <${ActionButton}
25
- onClick=${actions.refreshAll}
26
- tone="secondary"
27
- size="sm"
28
- idleLabel="Refresh"
29
- />
30
- `}
31
- />
89
+ <div class="cron-tab-header-content">
90
+ <${PageHeader}
91
+ leading=${html`
92
+ <div class="cron-tab-selector-shell" ref=${selectorShellRef}>
93
+ <button
94
+ type="button"
95
+ class=${`cron-tab-selector-toggle ${showJobSelector ? "is-open" : ""}`}
96
+ onClick=${() => setShowJobSelector((value) => !value)}
97
+ aria-expanded=${showJobSelector}
98
+ aria-haspopup="listbox"
99
+ >
100
+ <span class="cron-tab-selector-title">${selectedJobLabel}</span>
101
+ <span class="cron-tab-selector-caret">▾</span>
102
+ </button>
103
+ ${showJobSelector
104
+ ? html`
105
+ <div class="cron-tab-selector-dropdown">
106
+ <${CronJobList}
107
+ jobs=${state.jobs}
108
+ selectedRouteKey=${state.selectedRouteKey}
109
+ onSelectAllJobs=${handleSelectAllJobs}
110
+ onSelectJob=${handleSelectJob}
111
+ />
112
+ </div>
113
+ `
114
+ : null}
115
+ </div>
116
+ `}
117
+ actions=${html`
118
+ ${isAllJobsSelected || noJobs
119
+ ? html`
120
+ <${ActionButton}
121
+ onClick=${actions.refreshAll}
122
+ tone="secondary"
123
+ size="sm"
124
+ idleLabel="Refresh"
125
+ />
126
+ `
127
+ : html`
128
+ <${ActionButton}
129
+ onClick=${actions.saveChanges}
130
+ loading=${state.savingChanges}
131
+ disabled=${!hasUnsavedDetailChanges}
132
+ tone="primary"
133
+ size="sm"
134
+ idleLabel="Save changes"
135
+ loadingLabel="Saving..."
136
+ />
137
+ `}
138
+ `}
139
+ />
140
+ </div>
32
141
  </div>
33
142
  <div class="cron-tab-main">
34
- <aside
35
- ref=${refs.listPanelRef}
36
- class="cron-list-panel"
37
- style=${{ width: `${state.listPanelWidthPx}px` }}
38
- >
39
- <${CronJobList}
40
- jobs=${state.jobs}
41
- selectedRouteKey=${state.selectedRouteKey}
42
- onSelectAllJobs=${actions.selectAllJobs}
43
- onSelectJob=${actions.selectJob}
44
- />
45
- </aside>
46
- <div
47
- class=${`cron-list-resizer ${state.isResizingListPanel ? "is-resizing" : ""}`}
48
- onpointerdown=${actions.onListResizerPointerDown}
49
- role="separator"
50
- aria-orientation="vertical"
51
- aria-label="Resize cron jobs list"
52
- ></div>
53
- <main class="cron-detail-panel">
54
- ${noJobs
55
- ? html`
56
- <div class="h-full flex items-center justify-center text-sm text-gray-500">
57
- No cron jobs configured. Cron jobs are managed via the OpenClaw CLI.
58
- </div>
59
- `
60
- : isAllJobsSelected
143
+ <div class="cron-tab-main-content">
144
+ <main class="cron-detail-panel">
145
+ ${noJobs
61
146
  ? html`
62
- <${CronOverview}
63
- jobs=${state.jobs}
64
- status=${state.status}
65
- bulkUsageByJobId=${state.bulkUsageByJobId}
66
- bulkRunsByJobId=${state.bulkRunsByJobId}
67
- onSelectJob=${actions.selectJob}
68
- />
147
+ <div class="h-full flex items-center justify-center text-sm text-gray-500">
148
+ No cron jobs configured. Cron jobs are managed via the OpenClaw CLI.
149
+ </div>
69
150
  `
70
- : html`
71
- <${CronJobDetail}
72
- job=${state.selectedJob}
73
- runEntries=${state.runEntries}
74
- runTotal=${state.runTotal}
75
- runHasMore=${state.runHasMore}
76
- loadingMoreRuns=${state.loadingMoreRuns}
77
- runStatusFilter=${state.runStatusFilter}
78
- onSetRunStatusFilter=${actions.setRunStatusFilter}
79
- onLoadMoreRuns=${actions.loadMoreRuns}
80
- onRunNow=${actions.runSelectedJobNow}
81
- runningJob=${state.runningJob}
82
- onToggleEnabled=${actions.setSelectedJobEnabled}
83
- togglingJobEnabled=${state.togglingJobEnabled}
84
- usage=${state.usage}
85
- usageDays=${state.usageDays}
86
- onSetUsageDays=${actions.setUsageDays}
87
- promptValue=${state.promptValue}
88
- savedPromptValue=${state.savedPromptValue}
89
- onChangePrompt=${actions.setPromptValue}
90
- onSaveChanges=${actions.saveChanges}
91
- savingChanges=${state.savingChanges}
92
- routingDraft=${state.routingDraft}
93
- onChangeRoutingDraft=${actions.setRoutingDraft}
94
- deliverySessions=${state.deliverySessions}
95
- loadingDeliverySessions=${state.loadingDeliverySessions}
96
- deliverySessionsError=${state.deliverySessionsError}
97
- destinationSessionKey=${state.destinationSessionKey}
98
- onChangeDestinationSessionKey=${actions.setDestinationSessionKey}
99
- />
100
- `}
101
- </main>
151
+ : isAllJobsSelected
152
+ ? html`
153
+ <${CronOverview}
154
+ jobs=${state.jobs}
155
+ status=${state.status}
156
+ bulkUsageByJobId=${state.bulkUsageByJobId}
157
+ bulkRunsByJobId=${state.bulkRunsByJobId}
158
+ onSelectJob=${handleSelectJob}
159
+ />
160
+ `
161
+ : html`
162
+ <${CronJobDetail}
163
+ job=${state.selectedJob}
164
+ runEntries=${state.runEntries}
165
+ runTotal=${state.runTotal}
166
+ runHasMore=${state.runHasMore}
167
+ loadingMoreRuns=${state.loadingMoreRuns}
168
+ runStatusFilter=${state.runStatusFilter}
169
+ onSetRunStatusFilter=${actions.setRunStatusFilter}
170
+ onLoadMoreRuns=${actions.loadMoreRuns}
171
+ onRunNow=${actions.runSelectedJobNow}
172
+ runningJob=${state.runningJob}
173
+ onToggleEnabled=${actions.setSelectedJobEnabled}
174
+ togglingJobEnabled=${state.togglingJobEnabled}
175
+ usage=${state.usage}
176
+ usageDays=${state.usageDays}
177
+ onSetUsageDays=${actions.setUsageDays}
178
+ promptValue=${state.promptValue}
179
+ savedPromptValue=${state.savedPromptValue}
180
+ onChangePrompt=${actions.setPromptValue}
181
+ onSaveChanges=${actions.saveChanges}
182
+ savingChanges=${state.savingChanges}
183
+ routingDraft=${state.routingDraft}
184
+ onChangeRoutingDraft=${actions.setRoutingDraft}
185
+ deliverySessions=${state.deliverySessions}
186
+ loadingDeliverySessions=${state.loadingDeliverySessions}
187
+ deliverySessionsError=${state.deliverySessionsError}
188
+ destinationSessionKey=${state.destinationSessionKey}
189
+ onChangeDestinationSessionKey=${actions.setDestinationSessionKey}
190
+ />
191
+ `}
192
+ </main>
193
+ </div>
102
194
  </div>
103
195
  </div>
104
196
  `;
@@ -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
  };
@@ -13,6 +13,7 @@ const EditorTextarea = ({
13
13
  handleEditorSelectionChange,
14
14
  isEditBlocked,
15
15
  isPreviewOnly,
16
+ textareaWrap = "soft",
16
17
  }) => html`
17
18
  <textarea
18
19
  class=${overlay ? "file-viewer-editor file-viewer-editor-overlay" : "file-viewer-editor"}
@@ -33,7 +34,7 @@ const EditorTextarea = ({
33
34
  data-gramm="false"
34
35
  data-gramm_editor="false"
35
36
  data-enable-grammarly="false"
36
- wrap="soft"
37
+ wrap=${textareaWrap}
37
38
  ></textarea>
38
39
  `;
39
40
 
@@ -55,6 +56,7 @@ export const EditorSurface = ({
55
56
  handleEditorSelectionChange,
56
57
  isEditBlocked,
57
58
  isPreviewOnly,
59
+ textareaWrap = "soft",
58
60
  }) => html`
59
61
  <div class=${editorShellClassName} aria-hidden=${editorShellAriaHidden}>
60
62
  <div class="file-viewer-editor-line-num-col" ref=${editorLineNumbersRef}>
@@ -106,6 +108,7 @@ export const EditorSurface = ({
106
108
  handleEditorSelectionChange=${handleEditorSelectionChange}
107
109
  isEditBlocked=${isEditBlocked}
108
110
  isPreviewOnly=${isPreviewOnly}
111
+ textareaWrap=${textareaWrap}
109
112
  />
110
113
  </div>
111
114
  `
@@ -120,6 +123,7 @@ export const EditorSurface = ({
120
123
  handleEditorSelectionChange=${handleEditorSelectionChange}
121
124
  isEditBlocked=${isEditBlocked}
122
125
  isPreviewOnly=${isPreviewOnly}
126
+ textareaWrap=${textareaWrap}
123
127
  />
124
128
  `}
125
129
  </div>
@@ -0,0 +1,36 @@
1
+ import { useCallback, useEffect } from "https://esm.sh/preact/hooks";
2
+
3
+ export const useEditorLineNumberSync = ({
4
+ enabled = false,
5
+ syncKey = "",
6
+ editorLineNumberRowRefs,
7
+ editorHighlightLineRefs,
8
+ }) => {
9
+ const syncEditorLineNumberHeights = useCallback(() => {
10
+ if (!enabled) return;
11
+ const numberRows = editorLineNumberRowRefs?.current || [];
12
+ const highlightRows = editorHighlightLineRefs?.current || [];
13
+ const rowCount = Math.min(numberRows.length, highlightRows.length);
14
+ for (let index = 0; index < rowCount; index += 1) {
15
+ const numberRow = numberRows[index];
16
+ const highlightRow = highlightRows[index];
17
+ if (!numberRow || !highlightRow) continue;
18
+ numberRow.style.height = `${highlightRow.offsetHeight}px`;
19
+ }
20
+ }, [editorHighlightLineRefs, editorLineNumberRowRefs, enabled]);
21
+
22
+ useEffect(() => {
23
+ syncEditorLineNumberHeights();
24
+ }, [syncEditorLineNumberHeights, syncKey]);
25
+
26
+ useEffect(() => {
27
+ if (!enabled) return () => {};
28
+ const onResize = () => syncEditorLineNumberHeights();
29
+ window.addEventListener("resize", onResize);
30
+ return () => window.removeEventListener("resize", onResize);
31
+ }, [enabled, syncEditorLineNumberHeights]);
32
+
33
+ return {
34
+ syncEditorLineNumberHeights,
35
+ };
36
+ };