@chrysb/alphaclaw 0.4.6-beta.5 → 0.4.6-beta.7

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,142 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { ModalShell } from "./modal-shell.js";
5
+ import { ActionButton } from "./action-button.js";
6
+ import { PageHeader } from "./page-header.js";
7
+ import { CloseIcon } from "./icons.js";
8
+ import { useAgentSessions } from "../hooks/useAgentSessions.js";
9
+
10
+ const html = htm.bind(h);
11
+
12
+ export const AgentSendModal = ({
13
+ visible = false,
14
+ title = "Send to agent",
15
+ messageLabel = "Message",
16
+ messageRows = 8,
17
+ initialMessage = "",
18
+ resetKey = "",
19
+ submitLabel = "Send message",
20
+ loadingLabel = "Sending...",
21
+ cancelLabel = "Cancel",
22
+ onClose = () => {},
23
+ onSubmit = async () => true,
24
+ sessionFilter = undefined,
25
+ }) => {
26
+ const {
27
+ sessions,
28
+ selectedSessionKey,
29
+ setSelectedSessionKey,
30
+ selectedSession,
31
+ loading: loadingSessions,
32
+ error: loadError,
33
+ } = useAgentSessions({ enabled: visible, filter: sessionFilter });
34
+ const [messageText, setMessageText] = useState("");
35
+ const [sending, setSending] = useState(false);
36
+
37
+ useEffect(() => {
38
+ if (!visible) return;
39
+ setMessageText(String(initialMessage || ""));
40
+ }, [visible, initialMessage, resetKey]);
41
+
42
+ const handleSend = async () => {
43
+ if (!selectedSession || sending) return;
44
+ const trimmedMessage = String(messageText || "").trim();
45
+ if (!trimmedMessage) return;
46
+ setSending(true);
47
+ try {
48
+ const shouldClose = await onSubmit({
49
+ selectedSession,
50
+ selectedSessionKey,
51
+ message: trimmedMessage,
52
+ });
53
+ if (shouldClose !== false) {
54
+ onClose();
55
+ }
56
+ } finally {
57
+ setSending(false);
58
+ }
59
+ };
60
+
61
+ return html`
62
+ <${ModalShell}
63
+ visible=${visible}
64
+ onClose=${() => {
65
+ if (sending) return;
66
+ onClose();
67
+ }}
68
+ panelClassName="bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4"
69
+ >
70
+ <${PageHeader}
71
+ title=${title}
72
+ actions=${html`
73
+ <button
74
+ type="button"
75
+ onclick=${() => {
76
+ if (sending) return;
77
+ onClose();
78
+ }}
79
+ class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
80
+ aria-label="Close modal"
81
+ >
82
+ <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
83
+ </button>
84
+ `}
85
+ />
86
+ <div class="space-y-2">
87
+ <label class="text-xs text-gray-500">Send to session</label>
88
+ <select
89
+ value=${selectedSessionKey}
90
+ onChange=${(event) =>
91
+ setSelectedSessionKey(String(event.currentTarget?.value || ""))}
92
+ disabled=${loadingSessions || sending}
93
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 focus:border-gray-500"
94
+ >
95
+ ${sessions.length === 0
96
+ ? html`<option value="">No sessions available</option>`
97
+ : null}
98
+ ${sessions.map(
99
+ (sessionRow) => html`
100
+ <option value=${String(sessionRow?.key || "")}>
101
+ ${String(sessionRow?.label || sessionRow?.key || "Session")}
102
+ </option>
103
+ `,
104
+ )}
105
+ </select>
106
+ ${loadingSessions
107
+ ? html`<div class="text-xs text-gray-500">Loading sessions...</div>`
108
+ : null}
109
+ ${loadError ? html`<div class="text-xs text-red-400">${loadError}</div>` : null}
110
+ </div>
111
+ <div class="space-y-2">
112
+ <label class="text-xs text-gray-500">${messageLabel}</label>
113
+ <textarea
114
+ value=${messageText}
115
+ onInput=${(event) =>
116
+ setMessageText(String(event.currentTarget?.value || ""))}
117
+ disabled=${sending}
118
+ rows=${messageRows}
119
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 focus:border-gray-500 font-mono leading-5"
120
+ ></textarea>
121
+ </div>
122
+ <div class="flex items-center justify-end gap-2">
123
+ <${ActionButton}
124
+ onClick=${onClose}
125
+ disabled=${sending}
126
+ tone="secondary"
127
+ size="md"
128
+ idleLabel=${cancelLabel}
129
+ />
130
+ <${ActionButton}
131
+ onClick=${handleSend}
132
+ disabled=${!selectedSession || loadingSessions || !!loadError || !String(messageText || "").trim()}
133
+ loading=${sending}
134
+ tone="primary"
135
+ size="md"
136
+ idleLabel=${submitLabel}
137
+ loadingLabel=${loadingLabel}
138
+ />
139
+ </div>
140
+ </${ModalShell}>
141
+ `;
142
+ };
@@ -1,11 +1,8 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useEffect, useState } from "https://esm.sh/preact/hooks";
3
2
  import htm from "https://esm.sh/htm";
4
- import { ModalShell } from "../modal-shell.js";
5
- import { ActionButton } from "../action-button.js";
6
3
  import { sendDoctorCardFix, updateDoctorCardStatus } from "../../lib/api.js";
7
4
  import { showToast } from "../toast.js";
8
- import { useAgentSessions } from "../../hooks/useAgentSessions.js";
5
+ import { AgentSendModal } from "../agent-send-modal.js";
9
6
 
10
7
  const html = htm.bind(h);
11
8
 
@@ -15,33 +12,15 @@ export const DoctorFixCardModal = ({
15
12
  onClose = () => {},
16
13
  onComplete = () => {},
17
14
  }) => {
18
- const {
19
- sessions,
20
- selectedSessionKey,
21
- setSelectedSessionKey,
22
- selectedSession,
23
- loading: loadingSessions,
24
- error: loadError,
25
- } = useAgentSessions({ enabled: visible });
26
-
27
- const [sending, setSending] = useState(false);
28
- const [promptText, setPromptText] = useState("");
29
-
30
- useEffect(() => {
31
- if (!visible) return;
32
- setPromptText(String(card?.fixPrompt || ""));
33
- }, [visible, card?.fixPrompt, card?.id]);
34
-
35
- const handleSend = async () => {
36
- if (!card?.id || sending) return;
15
+ const handleSend = async ({ selectedSession, message }) => {
16
+ if (!card?.id) return false;
37
17
  try {
38
- setSending(true);
39
18
  await sendDoctorCardFix({
40
19
  cardId: card.id,
41
20
  sessionId: selectedSession?.sessionId || "",
42
21
  replyChannel: selectedSession?.replyChannel || "",
43
22
  replyTo: selectedSession?.replyTo || "",
44
- prompt: promptText,
23
+ prompt: message,
45
24
  });
46
25
  try {
47
26
  await updateDoctorCardStatus({ cardId: card.id, status: "fixed" });
@@ -57,77 +36,24 @@ export const DoctorFixCardModal = ({
57
36
  );
58
37
  }
59
38
  await onComplete();
60
- onClose();
39
+ return true;
61
40
  } catch (error) {
62
41
  showToast(error.message || "Could not send Doctor fix request", "error");
63
- } finally {
64
- setSending(false);
42
+ return false;
65
43
  }
66
44
  };
67
45
 
68
46
  return html`
69
- <${ModalShell}
47
+ <${AgentSendModal}
70
48
  visible=${visible}
49
+ title="Ask agent to fix"
50
+ messageLabel="Instructions"
51
+ initialMessage=${String(card?.fixPrompt || "")}
52
+ resetKey=${String(card?.id || "")}
53
+ submitLabel="Send fix request"
54
+ loadingLabel="Sending..."
71
55
  onClose=${onClose}
72
- panelClassName="bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4"
73
- >
74
- <div class="space-y-1">
75
- <h2 class="text-base font-semibold">Ask agent to fix</h2>
76
- <p class="text-xs text-gray-400">
77
- Send this Doctor finding to one of your agent sessions as a focused fix request.
78
- </p>
79
- </div>
80
- <div class="space-y-2">
81
- <label class="text-xs text-gray-500">Send to session</label>
82
- <select
83
- value=${selectedSessionKey}
84
- onChange=${(event) => setSelectedSessionKey(String(event.currentTarget?.value || ""))}
85
- disabled=${loadingSessions || sending}
86
- class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 focus:border-gray-500"
87
- >
88
- ${sessions.map(
89
- (sessionRow) => html`
90
- <option value=${String(sessionRow?.key || "")}>
91
- ${String(sessionRow?.label || sessionRow?.key || "Session")}
92
- </option>
93
- `,
94
- )}
95
- </select>
96
- ${
97
- loadingSessions
98
- ? html`<div class="text-xs text-gray-500">Loading sessions...</div>`
99
- : null
100
- }
101
- ${loadError ? html`<div class="text-xs text-red-400">${loadError}</div>` : null}
102
- </div>
103
- <div class="space-y-2">
104
- <label class="text-xs text-gray-500">Instructions</label>
105
- <textarea
106
- value=${promptText}
107
- onInput=${(event) => setPromptText(String(event.currentTarget?.value || ""))}
108
- disabled=${sending}
109
- rows="8"
110
- class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 focus:border-gray-500 font-mono leading-5"
111
- ></textarea>
112
- </div>
113
- <div class="flex items-center justify-end gap-2">
114
- <${ActionButton}
115
- onClick=${onClose}
116
- disabled=${sending}
117
- tone="secondary"
118
- size="md"
119
- idleLabel="Cancel"
120
- />
121
- <${ActionButton}
122
- onClick=${handleSend}
123
- disabled=${!selectedSession || loadingSessions || !!loadError || !String(promptText || "").trim()}
124
- loading=${sending}
125
- tone="primary"
126
- size="md"
127
- idleLabel="Send fix request"
128
- loadingLabel="Sending..."
129
- />
130
- </div>
131
- </${ModalShell}>
56
+ onSubmit=${handleSend}
57
+ />
132
58
  `;
133
59
  };
@@ -6,16 +6,44 @@ import { showToast } from "./toast.js";
6
6
  import { SecretInput } from "./secret-input.js";
7
7
  import { PageHeader } from "./page-header.js";
8
8
  import { ActionButton } from "./action-button.js";
9
+ import {
10
+ Brain2LineIcon,
11
+ ChatVoiceLineIcon,
12
+ ChevronDownIcon,
13
+ ImageAiLineIcon,
14
+ TextToSpeechLineIcon,
15
+ } from "./icons.js";
16
+ import { Tooltip } from "./tooltip.js";
9
17
  const html = htm.bind(h);
10
18
 
11
19
  const kGroupLabels = {
20
+ ai: "AI Provider Keys",
12
21
  github: "GitHub",
13
22
  channels: "Channels",
14
23
  tools: "Tools",
15
24
  custom: "Custom",
16
25
  };
17
26
 
18
- const kGroupOrder = ["github", "channels", "tools", "custom"];
27
+ const kGroupOrder = ["ai", "github", "channels", "tools", "custom"];
28
+ const kDefaultVisibleAiKeys = new Set(["OPENAI_API_KEY", "GEMINI_API_KEY"]);
29
+ const kFeatureIconByName = {
30
+ Embeddings: {
31
+ Icon: Brain2LineIcon,
32
+ label: "Memory embeddings",
33
+ },
34
+ Image: {
35
+ Icon: ImageAiLineIcon,
36
+ label: "Image generation",
37
+ },
38
+ TTS: {
39
+ Icon: TextToSpeechLineIcon,
40
+ label: "Text to speech",
41
+ },
42
+ STT: {
43
+ Icon: ChatVoiceLineIcon,
44
+ label: "Speech to text",
45
+ },
46
+ };
19
47
  const normalizeEnvVarKey = (raw) => raw.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
20
48
  const stripSurroundingQuotes = (raw) => {
21
49
  const value = String(raw || "").trim();
@@ -57,27 +85,80 @@ const kHintByKey = {
57
85
  ANTHROPIC_TOKEN: html`from <code class="text-xs bg-black/30 px-1 rounded">claude setup-token</code>`,
58
86
  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>`,
59
87
  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>`,
88
+ 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`,
60
89
  GITHUB_TOKEN: html`classic PAT · <code class="text-xs bg-black/30 px-1 rounded">repo</code> scope · <a href="https://github.com/settings/tokens" target="_blank" class="hover:underline" style="color: var(--accent-link)">github settings</a>`,
61
90
  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>`,
62
91
  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>`,
63
92
  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>`,
93
+ 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>`,
94
+ 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>`,
95
+ 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>`,
96
+ 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>`,
64
97
  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`,
65
98
  };
66
99
 
67
100
  const getHintContent = (envVar) => kHintByKey[envVar.key] || envVar.hint || "";
68
101
 
102
+ const getVisibleFeatureIcons = (envVar) =>
103
+ (Array.isArray(envVar?.features) ? envVar.features : []).filter(
104
+ (feature) => !!kFeatureIconByName[feature],
105
+ );
106
+
107
+ const splitAiVars = (items) => {
108
+ const visible = [];
109
+ const hidden = [];
110
+ (items || []).forEach((item) => {
111
+ const hasValue = !!String(item?.value || "").trim();
112
+ if (kDefaultVisibleAiKeys.has(item?.key) || hasValue) {
113
+ visible.push(item);
114
+ return;
115
+ }
116
+ hidden.push(item);
117
+ });
118
+ return { visible, hidden };
119
+ };
120
+
121
+ const FeatureIcon = ({ feature }) => {
122
+ const entry = kFeatureIconByName[feature];
123
+ if (!entry) return null;
124
+ const { Icon, label } = entry;
125
+ return html`
126
+ <${Tooltip} text=${label} widthClass="w-auto" tooltipClassName="whitespace-nowrap">
127
+ <span
128
+ class="inline-flex items-center justify-center text-gray-500 hover:text-gray-300 focus-within:text-gray-300"
129
+ tabindex="0"
130
+ aria-label=${label}
131
+ >
132
+ <${Icon} className="w-3.5 h-3.5" />
133
+ </span>
134
+ </${Tooltip}>
135
+ `;
136
+ };
137
+
69
138
  const EnvRow = ({ envVar, onChange, onDelete, disabled }) => {
70
139
  const hint = getHintContent(envVar);
140
+ const featureIcons = getVisibleFeatureIcons(envVar);
71
141
 
72
142
  return html`
73
143
  <div class="flex items-start gap-4 px-4 py-3">
74
- <div class="shrink-0 flex items-center gap-2 pt-1.5" style="width: 200px">
75
- <span
76
- class="inline-block w-1.5 h-1.5 rounded-full shrink-0 ${envVar.value
77
- ? "bg-green-500"
78
- : "bg-gray-600"}"
79
- />
80
- <code class="text-sm truncate">${envVar.key}</code>
144
+ <div class="shrink-0" style="width: 200px">
145
+ <div class="flex items-center gap-2 pt-1.5">
146
+ <span
147
+ class="inline-block w-1.5 h-1.5 rounded-full shrink-0 ${envVar.value
148
+ ? "bg-green-500"
149
+ : "bg-gray-600"}"
150
+ />
151
+ <code class="text-sm truncate">${envVar.key}</code>
152
+ </div>
153
+ ${featureIcons.length > 0
154
+ ? html`
155
+ <div class="flex items-center gap-2 mt-1 pl-3.5">
156
+ ${featureIcons.map(
157
+ (feature) => html`<${FeatureIcon} key=${feature} feature=${feature} />`,
158
+ )}
159
+ </div>
160
+ `
161
+ : null}
81
162
  </div>
82
163
  <div class="flex-1 min-w-0">
83
164
  <div class="flex items-center gap-1">
@@ -114,6 +195,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
114
195
  const [secretMaskEpoch, setSecretMaskEpoch] = useState(0);
115
196
  const [dirty, setDirty] = useState(false);
116
197
  const [saving, setSaving] = useState(false);
198
+ const [showAllAiKeys, setShowAllAiKeys] = useState(false);
117
199
  const [newKey, setNewKey] = useState("");
118
200
  const baselineSignatureRef = useRef("[]");
119
201
 
@@ -310,6 +392,61 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
310
392
  const pendingAtBottom = grouped.custom.filter((item) => pending.has(item.key));
311
393
  grouped.custom = [...nonPending, ...pendingAtBottom];
312
394
  }
395
+ const aiSplit = splitAiVars(grouped.ai || []);
396
+ const renderEnvRows = (items) =>
397
+ items.map(
398
+ (v) =>
399
+ html`<${EnvRow}
400
+ key=${`${secretMaskEpoch}:${v.key}`}
401
+ envVar=${v}
402
+ onChange=${handleChange}
403
+ onDelete=${handleDelete}
404
+ disabled=${saving}
405
+ />`,
406
+ );
407
+ const renderGroupCard = (groupKey) => {
408
+ const items = grouped[groupKey] || [];
409
+ if (!items.length) return null;
410
+ if (groupKey === "ai") {
411
+ const { visible, hidden } = aiSplit;
412
+ const expanded = showAllAiKeys && hidden.length > 0;
413
+ return html`
414
+ <div class="bg-surface border border-border rounded-xl overflow-hidden">
415
+ <h3 class="card-label text-xs px-4 pt-3 pb-2">
416
+ ${kGroupLabels[groupKey] || groupKey}
417
+ </h3>
418
+ <div class="divide-y divide-border">${renderEnvRows(visible)}</div>
419
+ ${hidden.length > 0
420
+ ? html`
421
+ <div class="border-t border-border px-4 py-2">
422
+ <button
423
+ type="button"
424
+ onclick=${() => setShowAllAiKeys((prev) => !prev)}
425
+ class="inline-flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300"
426
+ >
427
+ <${ChevronDownIcon}
428
+ className=${`transition-transform ${expanded ? "rotate-180" : ""}`}
429
+ />
430
+ ${expanded ? "Show fewer" : `Show more (${hidden.length})`}
431
+ </button>
432
+ </div>
433
+ `
434
+ : null}
435
+ ${expanded
436
+ ? html`<div class="divide-y divide-border border-t border-border">${renderEnvRows(hidden)}</div>`
437
+ : null}
438
+ </div>
439
+ `;
440
+ }
441
+ return html`
442
+ <div class="bg-surface border border-border rounded-xl overflow-hidden">
443
+ <h3 class="card-label text-xs px-4 pt-3 pb-2">
444
+ ${kGroupLabels[groupKey] || groupKey}
445
+ </h3>
446
+ <div class="divide-y divide-border">${renderEnvRows(items)}</div>
447
+ </div>
448
+ `;
449
+ };
313
450
 
314
451
  return html`
315
452
  <div class="space-y-4">
@@ -331,27 +468,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
331
468
 
332
469
  ${kGroupOrder
333
470
  .filter((g) => grouped[g]?.length)
334
- .map(
335
- (g) => html`
336
- <div class="bg-surface border border-border rounded-xl overflow-hidden">
337
- <h3 class="card-label text-xs px-4 pt-3 pb-2">
338
- ${kGroupLabels[g] || g}
339
- </h3>
340
- <div class="divide-y divide-border">
341
- ${grouped[g].map(
342
- (v) =>
343
- html`<${EnvRow}
344
- key=${`${secretMaskEpoch}:${v.key}`}
345
- envVar=${v}
346
- onChange=${handleChange}
347
- onDelete=${handleDelete}
348
- disabled=${saving}
349
- />`,
350
- )}
351
- </div>
352
- </div>
353
- `,
354
- )}
471
+ .map((g) => renderGroupCard(g))}
355
472
 
356
473
  <div class="bg-surface border border-border rounded-xl overflow-hidden">
357
474
  <div class="flex items-center justify-between px-4 pt-3 pb-2">
@@ -61,7 +61,7 @@ export const Features = ({ onSwitchTab }) => {
61
61
  href="#"
62
62
  onclick=${(e) => {
63
63
  e.preventDefault();
64
- onSwitchTab?.("providers");
64
+ onSwitchTab?.("envars");
65
65
  }}
66
66
  class="text-xs px-2 py-1 rounded-lg ac-btn-ghost"
67
67
  >Add provider</a>
@@ -326,6 +326,7 @@ export const Gateway = ({
326
326
  const showInspectButton = watchdogHealth === "degraded" && !!onOpenWatchdog;
327
327
  const showRepairButton =
328
328
  isRepairInProgress ||
329
+ (watchdogStatus?.health === "degraded" && !onOpenWatchdog) ||
329
330
  watchdogStatus?.lifecycle === "crash_loop" ||
330
331
  watchdogStatus?.health === "unhealthy" ||
331
332
  watchdogStatus?.health === "crashed";
@@ -81,6 +81,7 @@ export const GmailSetupWizard = ({
81
81
  }) => {
82
82
  const [step, setStep] = useState(0);
83
83
  const [projectIdInput, setProjectIdInput] = useState("");
84
+ const [editingProjectId, setEditingProjectId] = useState(false);
84
85
  const [localError, setLocalError] = useState("");
85
86
  const [projectIdResolved, setProjectIdResolved] = useState(false);
86
87
  const [watchEnabled, setWatchEnabled] = useState(false);
@@ -100,6 +101,7 @@ export const GmailSetupWizard = ({
100
101
  setStep(0);
101
102
  setLocalError("");
102
103
  setProjectIdInput("");
104
+ setEditingProjectId(false);
103
105
  setProjectIdResolved(false);
104
106
  setWatchEnabled(false);
105
107
  setSendingToAgent(false);
@@ -110,10 +112,11 @@ export const GmailSetupWizard = ({
110
112
  const hasProjectIdFromConfig = Boolean(
111
113
  String(clientConfig?.projectId || "").trim() || commands,
112
114
  );
113
- const needsProjectId = !hasProjectIdFromConfig && !projectIdResolved;
115
+ const needsProjectId =
116
+ editingProjectId || (!hasProjectIdFromConfig && !projectIdResolved);
114
117
  const detectedProjectId =
115
- String(clientConfig?.projectId || "").trim() ||
116
118
  String(projectIdInput || "").trim() ||
119
+ String(clientConfig?.projectId || "").trim() ||
117
120
  "<project-id>";
118
121
  const client =
119
122
  String(account?.client || clientConfig?.client || "default").trim() ||
@@ -135,6 +138,13 @@ export const GmailSetupWizard = ({
135
138
  showToast("Could not copy text", "error");
136
139
  }, []);
137
140
 
141
+ const handleChangeProjectId = useCallback(() => {
142
+ setLocalError("");
143
+ setProjectIdInput(String(clientConfig?.projectId || "").trim());
144
+ setProjectIdResolved(false);
145
+ setEditingProjectId(true);
146
+ }, [clientConfig?.projectId]);
147
+
138
148
  const handleFinish = async () => {
139
149
  try {
140
150
  setLocalError("");
@@ -159,6 +169,7 @@ export const GmailSetupWizard = ({
159
169
  client,
160
170
  projectId: String(projectIdInput || "").trim(),
161
171
  });
172
+ setEditingProjectId(false);
162
173
  setProjectIdResolved(true);
163
174
  } catch (err) {
164
175
  setLocalError(err.message || "Could not save project id");
@@ -230,7 +241,9 @@ export const GmailSetupWizard = ({
230
241
  <div
231
242
  class="rounded-lg border border-border bg-black/20 p-3 space-y-2"
232
243
  >
233
- <div class="text-sm">Project ID required</div>
244
+ <div class="text-sm">
245
+ ${editingProjectId ? "Change project ID" : "Project ID required"}
246
+ </div>
234
247
  <div class="text-xs text-gray-500">
235
248
  Find it in the${" "}
236
249
  <a
@@ -257,6 +270,9 @@ export const GmailSetupWizard = ({
257
270
  !needsProjectId && step === 0
258
271
  ? html`
259
272
  <div class="space-y-1">
273
+ <div class="text-xs text-gray-500">
274
+ Using project <code>${detectedProjectId}</code>.
275
+ </div>
260
276
  <div class="text-xs text-gray-500">
261
277
  If <code>gcloud</code> is not installed on your computer,
262
278
  follow the official install guide:${" "}
@@ -372,7 +388,15 @@ export const GmailSetupWizard = ({
372
388
  }
373
389
  <div class="grid grid-cols-2 gap-2 pt-2">
374
390
  ${step === 0
375
- ? html`<div></div>`
391
+ ? html`${!needsProjectId
392
+ ? html`<button
393
+ type="button"
394
+ onclick=${handleChangeProjectId}
395
+ class="justify-self-start text-xs px-2 py-1 rounded-lg ac-btn-ghost"
396
+ >
397
+ Change project ID
398
+ </button>`
399
+ : html`<div></div>`}`
376
400
  : html`<${ActionButton}
377
401
  onClick=${() => setStep((prev) => Math.max(prev - 1, 0))}
378
402
  disabled=${saving}