@chrysb/alphaclaw 0.4.6-beta.6 → 0.4.6-beta.8

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
  };
@@ -1,12 +1,16 @@
1
1
  export const getDoctorPriorityTone = (priority = "") => {
2
- const normalized = String(priority || "").trim().toUpperCase();
2
+ const normalized = String(priority || "")
3
+ .trim()
4
+ .toUpperCase();
3
5
  if (normalized === "P0") return "danger";
4
6
  if (normalized === "P1") return "warning";
5
7
  return "neutral";
6
8
  };
7
9
 
8
10
  export const getDoctorStatusTone = (status = "") => {
9
- const normalized = String(status || "").trim().toLowerCase();
11
+ const normalized = String(status || "")
12
+ .trim()
13
+ .toLowerCase();
10
14
  if (normalized === "fixed") return "success";
11
15
  if (normalized === "dismissed") return "neutral";
12
16
  return "warning";
@@ -35,7 +39,9 @@ export const formatDoctorCategory = (category = "") => {
35
39
  export const buildDoctorPriorityCounts = (cards = []) =>
36
40
  cards.reduce(
37
41
  (totals, card) => {
38
- const priority = String(card?.priority || "").trim().toUpperCase();
42
+ const priority = String(card?.priority || "")
43
+ .trim()
44
+ .toUpperCase();
39
45
  if (priority === "P0" || priority === "P1" || priority === "P2") {
40
46
  totals[priority] += 1;
41
47
  }
@@ -47,7 +53,9 @@ export const buildDoctorPriorityCounts = (cards = []) =>
47
53
  export const groupDoctorCardsByStatus = (cards = []) =>
48
54
  cards.reduce(
49
55
  (groups, card) => {
50
- const status = String(card?.status || "open").trim().toLowerCase();
56
+ const status = String(card?.status || "open")
57
+ .trim()
58
+ .toLowerCase();
51
59
  if (status === "fixed") {
52
60
  groups.fixed.push(card);
53
61
  return groups;
@@ -74,13 +82,71 @@ export const shouldShowDoctorWarning = (
74
82
 
75
83
  export const getDoctorWarningMessage = (doctorStatus = null) => {
76
84
  if (!doctorStatus) return "";
77
- const changedFilesCount = Number(doctorStatus.changeSummary?.changedFilesCount || 0);
85
+ const changedFilesCount = Number(
86
+ doctorStatus.changeSummary?.changedFilesCount || 0,
87
+ );
78
88
  if (changedFilesCount > 0) {
79
89
  return `Drift Doctor has not been run in the last week and ${changedFilesCount} file${changedFilesCount === 1 ? "" : "s"} changed since the last review.`;
80
90
  }
81
91
  return "Doctor has not been run in the last week.";
82
92
  };
83
93
 
94
+ export const formatDoctorCharCount = (value = 0) =>
95
+ `${Number(value || 0).toLocaleString()} chars`;
96
+
97
+ const isManagedBootstrapContextPath = (filePath = "") =>
98
+ String(filePath || "").startsWith("hooks/bootstrap/");
99
+
100
+ export const getDoctorBootstrapTruncationItems = (doctorStatus = null) => {
101
+ const bootstrapContext = doctorStatus?.bootstrapContext;
102
+ const truncatedFiles = (bootstrapContext?.activeTruncatedFiles || []).filter(
103
+ (file) => !isManagedBootstrapContextPath(file?.path),
104
+ );
105
+ const nearLimitFiles = (bootstrapContext?.activeNearLimitFiles || []).filter(
106
+ (file) => !isManagedBootstrapContextPath(file?.path),
107
+ );
108
+ return [
109
+ ...truncatedFiles.map((file) => ({
110
+ path: file.path,
111
+ size: formatDoctorCharCount(file.rawChars),
112
+ statusText: `-${Number(
113
+ Math.max(
114
+ 0,
115
+ Number(file.rawChars || 0) - Number(file.injectedChars || 0),
116
+ ),
117
+ ).toLocaleString()} cut`,
118
+ statusTone: "danger",
119
+ })),
120
+ ...nearLimitFiles.map((file) => ({
121
+ path: file.path,
122
+ size: formatDoctorCharCount(file.rawChars),
123
+ statusText: "Near limit",
124
+ statusTone: "warning",
125
+ })),
126
+ ];
127
+ };
128
+
129
+ export const hasDoctorBootstrapWarnings = (doctorStatus = null) =>
130
+ getDoctorBootstrapTruncationItems(doctorStatus).length > 0;
131
+
132
+ export const getDoctorBootstrapWarningTitle = (doctorStatus = null) => {
133
+ const items = getDoctorBootstrapTruncationItems(doctorStatus);
134
+ if (!items.length) return "";
135
+ const hasTruncatedItems = items.some((item) => item.statusTone === "danger");
136
+ const hasNearLimitItems = items.some((item) => item.statusTone === "warning");
137
+ if (hasTruncatedItems && hasNearLimitItems) {
138
+ return "Some of your main files are being truncated or nearing the limit:";
139
+ }
140
+ if (hasNearLimitItems) {
141
+ return items.length === 1
142
+ ? "One of your main files is nearing the limit:"
143
+ : "Some of your main files are nearing the limit:";
144
+ }
145
+ return items.length === 1
146
+ ? "One of your main files is being truncated:"
147
+ : "Some of your main files are being truncated:";
148
+ };
149
+
84
150
  export const getDoctorChangeLabel = (changeSummary = null) => {
85
151
  const changedFilesCount = Number(changeSummary?.changedFilesCount || 0);
86
152
  if (changedFilesCount === 0) return "No changes since last run";
@@ -20,8 +20,11 @@ import { DoctorFixCardModal } from "./fix-card-modal.js";
20
20
  import {
21
21
  buildDoctorRunMarkers,
22
22
  buildDoctorStatusFilterOptions,
23
+ getDoctorBootstrapTruncationItems,
24
+ getDoctorBootstrapWarningTitle,
23
25
  getDoctorChangeLabel,
24
26
  getDoctorRunPillDetail,
27
+ hasDoctorBootstrapWarnings,
25
28
  shouldShowDoctorWarning,
26
29
  } from "./helpers.js";
27
30
 
@@ -182,6 +185,18 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => {
182
185
  () => shouldShowDoctorWarning(doctorStatus, 0),
183
186
  [doctorStatus],
184
187
  );
188
+ const showBootstrapTruncationBanner = useMemo(
189
+ () => hasDoctorBootstrapWarnings(doctorStatus),
190
+ [doctorStatus],
191
+ );
192
+ const bootstrapTruncationMessage = useMemo(
193
+ () => getDoctorBootstrapWarningTitle(doctorStatus),
194
+ [doctorStatus],
195
+ );
196
+ const bootstrapTruncationItems = useMemo(
197
+ () => getDoctorBootstrapTruncationItems(doctorStatus),
198
+ [doctorStatus],
199
+ );
185
200
  const hasCompletedDoctorRun = !!doctorStatus?.lastRunAt;
186
201
  const hasRuns = runs.length > 0;
187
202
  const hasLoadedRuns = runsPoll.data !== null || runsPoll.error !== null;
@@ -312,29 +327,76 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => {
312
327
  : null}
313
328
  ${!showInitialLoadingState && hasRuns
314
329
  ? html`
315
- <${DoctorSummaryCards} cards=${openCards} />
316
- <div class="space-y-2">
317
- ${hasCompletedDoctorRun
318
- ? html`
319
- <div
320
- class="bg-surface border border-border rounded-xl p-4 flex flex-wrap items-center justify-between gap-3"
321
- >
322
- <span class="text-xs text-gray-500">
323
- Last run ·${" "}
324
- <span class="text-gray-300">
325
- ${formatLocaleDateTime(doctorStatus?.lastRunAt, {
326
- fallback: "Never",
327
- })}
330
+ <div class="space-y-3">
331
+ <${DoctorSummaryCards} cards=${openCards} />
332
+ <div class="space-y-3">
333
+ ${hasCompletedDoctorRun
334
+ ? html`
335
+ <div
336
+ class="bg-surface border border-border rounded-xl p-4 flex flex-wrap items-center justify-between gap-3"
337
+ >
338
+ <span class="text-xs text-gray-500">
339
+ Last run ·${" "}
340
+ <span class="text-gray-300">
341
+ ${formatLocaleDateTime(doctorStatus?.lastRunAt, {
342
+ fallback: "Never",
343
+ })}
344
+ </span>
328
345
  </span>
329
- </span>
330
- <span class="text-xs text-gray-500">
331
- ${changeLabel}
332
- </span>
333
- </div>
334
- `
335
- : null}
336
- ${
337
- showDoctorStaleBanner
346
+ <span class="text-xs text-gray-500">
347
+ ${changeLabel}
348
+ </span>
349
+ </div>
350
+ ${showBootstrapTruncationBanner
351
+ ? html`
352
+ <div
353
+ class="bg-surface border border-border rounded-xl p-4 space-y-3"
354
+ >
355
+ <div class="text-xs text-gray-400">
356
+ ⚠️ ${bootstrapTruncationMessage}
357
+ </div>
358
+ <div class="space-y-2">
359
+ ${bootstrapTruncationItems.map(
360
+ (item) => html`
361
+ <div
362
+ class="flex items-center justify-between gap-3 text-xs"
363
+ >
364
+ <button
365
+ type="button"
366
+ class="font-mono text-gray-200 ac-tip-link hover:underline text-left cursor-pointer"
367
+ onClick=${() => onOpenFile(String(item.path || ""))}
368
+ >
369
+ ${item.path}
370
+ </button>
371
+ <span
372
+ class="flex items-center gap-3 whitespace-nowrap"
373
+ >
374
+ <span class="text-gray-500">
375
+ ${item.size}
376
+ </span>
377
+ <span
378
+ class=${item.statusTone === "warning"
379
+ ? "text-yellow-300"
380
+ : "text-red-300"}
381
+ >
382
+ ${item.statusText}
383
+ </span>
384
+ </span>
385
+ </div>
386
+ `,
387
+ )}
388
+ </div>
389
+ <div class="border-t border-border"></div>
390
+ <p class="text-xs text-gray-500 leading-5">
391
+ Truncated files become partially hidden from
392
+ your agent and could cause drift.
393
+ </p>
394
+ </div>
395
+ `
396
+ : null}
397
+ `
398
+ : null}
399
+ ${showDoctorStaleBanner
338
400
  ? html`
339
401
  <div
340
402
  class="text-xs text-yellow-300 bg-yellow-500/10 border border-yellow-500/35 rounded-lg px-3 py-2"
@@ -344,8 +406,8 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => {
344
406
  changed.
345
407
  </div>
346
408
  `
347
- : null
348
- }
409
+ : null}
410
+ </div>
349
411
  </div>
350
412
  `
351
413
  : null}
@@ -461,9 +523,7 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => {
461
523
  ${selectedRunIsInProgress
462
524
  ? html`
463
525
  <div class="ac-surface-inset rounded-xl p-4">
464
- <div
465
- class="text-xs leading-5 text-gray-400"
466
- >
526
+ <div class="text-xs leading-5 text-gray-400">
467
527
  <span
468
528
  >Run in progress. Findings will appear when analysis
469
529
  completes.</span
@@ -479,7 +539,8 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => {
479
539
  onAskAgentFix=${setFixCard}
480
540
  onUpdateStatus=${handleUpdateStatus}
481
541
  onOpenFile=${onOpenFile}
482
- changedPaths=${doctorStatus?.changeSummary?.changedPaths || []}
542
+ changedPaths=${doctorStatus?.changeSummary?.changedPaths ||
543
+ []}
483
544
  showRunMeta=${selectedRunFilter === "all"}
484
545
  hideEmptyState=${selectedRunIsInProgress}
485
546
  />
@@ -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}