clay-server 4.0.0-beta.1 → 4.0.0-beta.3

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.
@@ -37,7 +37,7 @@ import { updateSettingsStats, updateDaemonConfig, handleSetPinResult, handleKeep
37
37
  import { handleTermList, handleTermCreated, sendTerminalCommand, handleTermOutput, handleTermResized, handleTermExited, handleTermClosed } from './terminal.js';
38
38
  import { attachTuiView, detachTuiView, setTuiSuspendedView, tuiHandleTermOutput, tuiHandleTermResized, tuiHandleTermExited, tuiHandleTermClosed } from './session-tui-view.js';
39
39
  import { handleTuiTranscriptState } from './tui-grab.js';
40
- import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTermExited, tuiModalHandleTermClosed, openTuiModal } from './tui-attention.js';
40
+ import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTermExited, tuiModalHandleTermClosed } from './tui-attention.js';
41
41
  import { updateTerminalList, handleContextSourcesState, updateEmailAccountList, updateEmailUnreadCounts, handleEmailTestResult, handleEmailAddResult, handleEmailRemoveResult } from './context-sources.js';
42
42
  import { refreshEmailSettings } from './user-settings.js';
43
43
  import { handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted, handleNoteWritten } from './sticky-notes.js';
@@ -72,7 +72,8 @@ import { handleRemoteCursorMove, handleRemoteCursorLeave, handleRemoteSelection,
72
72
  import { showLoopBanner, updateLoopBanner, updateLoopInputVisibility, showRalphApprovalBar, updateRalphApprovalStatus, openRalphPreviewModal, showExecModal, updateExecModalStatus } from './app-loop-ui.js';
73
73
  import { showDebateSticky, showDebateConcludeConfirm, showDebateUserFloor, exitDebateFloorMode, exitDebateConcludeMode, exitDebateEndedMode, updateDebateRound, renderDebateUserFloorDone } from './app-debate-ui.js';
74
74
  import { handleSkillInstallWs } from './app-skills-install.js';
75
- import { handleNotificationsState, handleNotificationCreated, handleNotificationDismissed, handleNotificationDismissedAll, showUpdateBanner, autoStartLoginIfNeeded } from './app-notifications.js';
75
+ import { handleNotificationsState, handleNotificationCreated, handleNotificationDismissed, handleNotificationDismissedAll, showUpdateBanner } from './app-notifications.js';
76
+ import { autoStartLoginIfNeeded, handleVendorLoginReady, handleVendorLoginState, handleVendorLoginError, handleAuthRefreshed } from './vendor-login.js';
76
77
  import { handleDebatePreparing, handleDebateBriefReady, renderDebateBriefReady, handleDebateStarted, renderDebateStarted, handleDebateTurn, handleDebateActivity, handleDebateStream, handleDebateTurnDone, handleDebateCommentQueued, handleDebateCommentInjected, renderDebateCommentInjected, handleDebateResumed, handleDebateEnded, renderDebateEnded, handleDebateError, isDebateActive, renderMcpDebateProposal, renderDebateUserResume } from './debate.js';
77
78
  import { handleMentionStart, handleMentionActivity, handleMentionStream, handleMentionDone, handleMentionError, renderMentionUser, renderMentionResponse, renderUserMention } from './mention.js';
78
79
 
@@ -1299,11 +1300,28 @@ export function processMessage(msg) {
1299
1300
  appendDelta((msg.text || "Authentication required.") + "\n");
1300
1301
  setStatus("connected");
1301
1302
  if (!store.get('loopActive')) enableMainInput();
1302
- // Auto-open the login modal terminal when the session can self-login.
1303
- // No-op otherwise (the auth_required banner remains the manual path).
1303
+ // Open the login flow for this vendor. The server keeps exactly one
1304
+ // login terminal per vendor, so repeat auth_required events (other
1305
+ // sessions, split panes, a 401 retry burst) do not stack up.
1304
1306
  autoStartLoginIfNeeded(msg);
1305
1307
  break;
1306
1308
 
1309
+ case "vendor_login_ready":
1310
+ handleVendorLoginReady(msg);
1311
+ break;
1312
+
1313
+ case "vendor_login_state":
1314
+ handleVendorLoginState(msg);
1315
+ break;
1316
+
1317
+ case "vendor_login_error":
1318
+ handleVendorLoginError(msg);
1319
+ break;
1320
+
1321
+ case "auth_refreshed":
1322
+ handleAuthRefreshed(msg);
1323
+ break;
1324
+
1307
1325
  case "rate_limit":
1308
1326
  handleRateLimitEvent(msg);
1309
1327
  break;
@@ -1492,19 +1510,8 @@ export function processMessage(msg) {
1492
1510
  break;
1493
1511
 
1494
1512
  case "term_created":
1495
- // Login-modal path: the terminal was created with the login command as
1496
- // its initial input; attach the modal dialog to it (replays scrollback
1497
- // so the login URL is visible) instead of the sidebar terminal.
1498
- if (store.get('pendingLoginModal')) {
1499
- var _lm = store.get('pendingLoginModal');
1500
- store.set({ pendingLoginModal: null });
1501
- openTuiModal(msg.id, _lm.slug, {
1502
- sessionTitle: (VENDOR_NAMES[_lm.vendor] || "Claude Code") + " login",
1503
- projectName: _lm.slug,
1504
- compact: true,
1505
- });
1506
- break;
1507
- }
1513
+ // Login terminals never come through here: they are created by the
1514
+ // server-owned flow and announced with vendor_login_ready instead.
1508
1515
  handleTermCreated(msg);
1509
1516
  if (store.get('pendingTermCommand')) {
1510
1517
  var cmd = store.get('pendingTermCommand');
@@ -10,8 +10,8 @@ import { showHomeHub } from './app-home-hub.js';
10
10
  import { openHomeChat } from './home-mate-chat.js';
11
11
  import { getCachedProjects, switchProject, renderProjectList } from './app-projects.js';
12
12
  import { mateAvatarUrl, userAvatarUrl } from './avatar.js';
13
- import { openTerminal } from './terminal.js';
14
13
  import { openTuiModal } from './tui-attention.js';
14
+ import { requestVendorLogin } from './vendor-login.js';
15
15
  import { startUrgentBlink, stopUrgentBlink } from './app-favicon.js';
16
16
  import { playDoneSound, isNotifSoundEnabled } from './notifications.js';
17
17
  var notifications = [];
@@ -47,7 +47,6 @@ var pendingTuiTotal = 0;
47
47
  // (next hour) acts as a fresh ping.
48
48
  var pendingUpdateMsg = null;
49
49
  var activeAuthRequiredMsg = null;
50
- var authReminderVisible = false;
51
50
 
52
51
  // ========================================================
53
52
  // Init
@@ -91,7 +90,6 @@ function showAllBanners() {
91
90
  }
92
91
 
93
92
  if (activeAuthRequiredMsg) showAuthRequiredBanner(activeAuthRequiredMsg);
94
- if (authReminderVisible) showLoginReminderBanner();
95
93
 
96
94
  // Check if any banner actually got rendered (update/auth banners can be suppressed)
97
95
  var hasVisibleBanner = bannerContainer.children.length > 0;
@@ -266,8 +264,9 @@ function showBanner(notif, autoDismissMs) {
266
264
  removeBanner(banner);
267
265
  dismissNotif(notif.id);
268
266
  var authMeta = notif.meta || {};
269
- startLoginInModal(authMeta.loginCommand || getVendorLoginCommand(authMeta.vendor || "claude"), authMeta.vendor || "claude");
270
- showLoginReminderBanner();
267
+ // Deliberate click: re-attach to a live login terminal instead of
268
+ // spawning a second one (the server dedups per vendor).
269
+ requestVendorLogin(authMeta.vendor || "claude", { sessionId: notif.sessionId || null });
271
270
  });
272
271
  }
273
272
  }
@@ -325,118 +324,6 @@ function showBanner(notif, autoDismissMs) {
325
324
  }
326
325
  }
327
326
 
328
- // Open the login terminal as a modal dialog (the same modal used for TUI
329
- // attention), running the vendor login command. The terminal is created with
330
- // the command as its initial input, so when the modal attaches it replays the
331
- // scrollback and the user sees the login URL / prompts immediately.
332
- // Falls back to the sidebar terminal if the current project slug is unknown
333
- // (the modal needs a slug to open its own WS to the project).
334
- function currentProjectSlug() {
335
- var slug = store.get('currentSlug') || "";
336
- if (slug) return slug;
337
- // Fallback: derive from the URL (/p/<slug>/...).
338
- try {
339
- var m = (window.location.pathname || "").match(/^\/p\/([a-z0-9_-]+)/);
340
- if (m) return m[1];
341
- } catch (e) {}
342
- return "";
343
- }
344
-
345
- function startLoginInModal(loginCommand, vendor) {
346
- if (authReminderVisible) return;
347
- var ws = getWs();
348
- if (!ws || ws.readyState !== 1) return;
349
- var cmd = loginCommand || getVendorLoginCommand(vendor);
350
- var slug = currentProjectSlug();
351
- if (!slug) { startLoginCommand(cmd); return; }
352
- store.set({ pendingLoginModal: { slug: slug, vendor: vendor || "claude" } });
353
- ws.send(JSON.stringify({
354
- type: "term_create",
355
- cols: 100,
356
- rows: 30,
357
- initialCommand: cmd + "\n",
358
- }));
359
- }
360
-
361
- // Sidebar-terminal fallback (used when no slug is available for the modal).
362
- function startLoginCommand(loginCommand) {
363
- if (authReminderVisible) return;
364
- var ws = getWs();
365
- if (!ws || ws.readyState !== 1) return;
366
- var termCommand = (loginCommand || "claude login") + "\n";
367
- store.set({ pendingTermCommand: termCommand });
368
- ws.send(JSON.stringify({ type: "term_create", cols: 80, rows: 24 }));
369
- openTerminal();
370
- }
371
-
372
- // Auto-open the login modal and run the login command when the server reports
373
- // the vendor isn't logged in. The popup terminal runs as the connected user's
374
- // own OS identity (term_create -> getOsUserInfoForWs), so login always targets
375
- // the right account — we pop it unconditionally rather than gating on
376
- // canAutoLogin. Idempotent: startLoginInModal is guarded by authReminderVisible
377
- // so repeat auth_required events no-op.
378
- export function autoStartLoginIfNeeded(msg) {
379
- if (!msg) return false;
380
- if (authReminderVisible) return false;
381
- var vendor = msg.vendor || "claude";
382
- var cmd = msg.loginCommand
383
- || getVendorLoginCommand(vendor);
384
- startLoginInModal(cmd, vendor);
385
- showLoginReminderBanner();
386
- return true;
387
- }
388
-
389
- function showLoginReminderBanner() {
390
- if (!bannerContainer) return;
391
- authReminderVisible = true;
392
- var existing = bannerContainer.querySelector('[data-auth-reminder="true"]');
393
- if (existing) removeBanner(existing);
394
- var banner = document.createElement("div");
395
- banner.className = "notif-banner notif-banner-update";
396
- banner.setAttribute("data-notif-id", "_auth_reminder");
397
- banner.setAttribute("data-auth-reminder", "true");
398
- banner.innerHTML =
399
- '<div class="notif-banner-icon">' + iconHtml("check-circle") + '</div>' +
400
- '<div class="notif-banner-body">' +
401
- '<div class="notif-banner-project">CLAY</div>' +
402
- '<div class="notif-banner-title">Login started</div>' +
403
- '<div class="notif-banner-text">After the login completes, open a new session so Clay picks up the fresh auth state.</div>' +
404
- '<div class="notif-banner-actions">' +
405
- '<button class="notif-banner-new-session notif-banner-update-now">Open new session</button>' +
406
- '</div>' +
407
- '</div>' +
408
- '<button class="notif-banner-close">' + iconHtml("x") + '</button>';
409
-
410
- bannerContainer.appendChild(banner);
411
- refreshIcons();
412
-
413
- requestAnimationFrame(function () {
414
- banner.classList.add("show");
415
- });
416
-
417
- var newSessionBtn = banner.querySelector(".notif-banner-new-session");
418
- if (newSessionBtn) {
419
- newSessionBtn.addEventListener("click", function (e) {
420
- e.stopPropagation();
421
- authReminderVisible = false;
422
- var ws = getWs();
423
- if (ws && ws.readyState === 1) {
424
- ws.send(JSON.stringify({ type: "new_session" }));
425
- }
426
- removeBanner(banner);
427
- });
428
- }
429
-
430
- var closeBtn = banner.querySelector(".notif-banner-close");
431
- if (closeBtn) {
432
- closeBtn.addEventListener("click", function (e) {
433
- e.stopPropagation();
434
- authReminderVisible = false;
435
- removeBanner(banner);
436
- });
437
- }
438
- }
439
-
440
327
  export function showAuthRequiredBanner(msg) {
441
328
  if (!bannerContainer) return;
442
329
  var vendor = (msg && (msg.vendor || (msg.meta && msg.meta.vendor))) || "claude";
@@ -1,55 +1,149 @@
1
+ // background-tasks-ui.js - Background task status line above the composer.
2
+ //
3
+ // Collapsed it reads as a quiet status line, not a button demanding attention:
4
+ // a muted activity indicator, a count, and a chevron. Expanding lists each task
5
+ // with its elapsed time and a Stop control.
6
+ //
7
+ // Elapsed time comes from `started_at` (epoch ms) which the server stamps in
8
+ // lib/background-task-timing.js. The ticker only runs while the list is
9
+ // expanded, so a collapsed bar costs nothing.
10
+
1
11
  import { store } from './store.js';
2
12
  import { getWs } from './ws-ref.js';
3
13
  import { escapeHtml } from './utils.js';
14
+ import { iconHtml, refreshIcons } from './icons.js';
4
15
 
5
16
  var expanded = false;
17
+ var elapsedTimer = null;
18
+
19
+ // Lucide icon per vendor-neutral task type (see normalizeBackgroundTasks in
20
+ // yoke/adapters/claude.js for where these types are assigned).
21
+ var TASK_TYPE_ICONS = { shell: "terminal", agent: "sparkles", other: "circle-dot" };
6
22
 
7
23
  export function initBackgroundTasksUi() {
8
- store.subscribe(function(state, prev) {
24
+ store.subscribe(function (state, prev) {
9
25
  if (state.activeBackgroundTasks !== prev.activeBackgroundTasks) renderBackgroundTasks();
10
26
  });
11
27
  renderBackgroundTasks();
12
28
  }
13
29
 
30
+ // "45s", "2m 14s", "1h 03m" - short enough to sit in a row without wrapping.
31
+ export function formatElapsed(startedAt, now) {
32
+ if (typeof startedAt !== "number" || !isFinite(startedAt) || startedAt <= 0) return "";
33
+ var current = typeof now === "number" ? now : Date.now();
34
+ var totalSeconds = Math.floor((current - startedAt) / 1000);
35
+ if (totalSeconds < 0) totalSeconds = 0;
36
+ if (totalSeconds < 60) return totalSeconds + "s";
37
+ var minutes = Math.floor(totalSeconds / 60);
38
+ var seconds = totalSeconds % 60;
39
+ if (minutes < 60) return minutes + "m " + pad(seconds) + "s";
40
+ var hours = Math.floor(minutes / 60);
41
+ return hours + "h " + pad(minutes % 60) + "m";
42
+ }
43
+
44
+ function pad(value) {
45
+ return value < 10 ? "0" + value : String(value);
46
+ }
47
+
48
+ function stopElapsedTimer() {
49
+ if (!elapsedTimer) return;
50
+ clearInterval(elapsedTimer);
51
+ elapsedTimer = null;
52
+ }
53
+
54
+ // Ticks at most once a second and only touches the time cells, so expanding
55
+ // the list never re-renders (and never steals focus from) the rows.
56
+ function startElapsedTimer() {
57
+ stopElapsedTimer();
58
+ elapsedTimer = setInterval(updateElapsedCells, 1000);
59
+ }
60
+
61
+ function updateElapsedCells() {
62
+ var bar = document.getElementById('background-tasks-bar');
63
+ if (!bar || !expanded) {
64
+ stopElapsedTimer();
65
+ return;
66
+ }
67
+ var now = Date.now();
68
+ var cells = bar.querySelectorAll('.background-task-elapsed');
69
+ for (var i = 0; i < cells.length; i++) {
70
+ var startedAt = Number(cells[i].dataset.startedAt);
71
+ cells[i].textContent = formatElapsed(startedAt, now);
72
+ }
73
+ }
74
+
75
+ function activityDotsHtml() {
76
+ return '<span class="background-tasks-activity" aria-hidden="true"><i></i><i></i><i></i></span>';
77
+ }
78
+
79
+ function taskRowHtml(task, now) {
80
+ var taskType = task.task_type || 'other';
81
+ var icon = TASK_TYPE_ICONS[taskType] || TASK_TYPE_ICONS.other;
82
+ var description = task.description || 'Background task';
83
+ var startedAt = typeof task.started_at === 'number' ? task.started_at : 0;
84
+ var elapsed = formatElapsed(startedAt, now);
85
+ return '<div class="background-task-row">' +
86
+ '<span class="background-task-icon" title="' + escapeHtml(taskType) + '">' + iconHtml(icon) + '</span>' +
87
+ '<span class="background-task-description" title="' + escapeHtml(description) + '">' +
88
+ escapeHtml(description) + '</span>' +
89
+ '<span class="background-task-meta">' +
90
+ '<span class="background-task-type">' + escapeHtml(taskType) + '</span>' +
91
+ '<span class="background-task-elapsed" data-started-at="' + startedAt + '">' + escapeHtml(elapsed) + '</span>' +
92
+ '</span>' +
93
+ '<button type="button" class="background-task-stop" data-task-id="' + escapeHtml(task.task_id || '') + '" ' +
94
+ 'aria-label="Stop ' + escapeHtml(description) + '">Stop</button>' +
95
+ '</div>';
96
+ }
97
+
14
98
  function renderBackgroundTasks() {
15
99
  var tasks = store.get('activeBackgroundTasks') || [];
16
100
  var existing = document.getElementById('background-tasks-bar');
17
101
  if (tasks.length === 0) {
18
102
  if (existing) existing.remove();
19
103
  expanded = false;
104
+ stopElapsedTimer();
20
105
  return;
21
106
  }
22
107
  var inputArea = document.getElementById('input-area');
23
108
  if (!inputArea || !inputArea.parentNode) return;
109
+
24
110
  var bar = existing || document.createElement('div');
25
111
  bar.id = 'background-tasks-bar';
26
- bar.className = 'background-tasks-bar';
112
+ bar.className = 'background-tasks-bar' + (expanded ? ' expanded' : '');
113
+
27
114
  var taskLabel = tasks.length === 1 ? 'background task' : 'background tasks';
28
- var html = '<button type="button" class="background-tasks-toggle" aria-expanded="' + expanded + '">' +
29
- '<span aria-hidden="true">⏳</span><span class="background-tasks-chip">' + tasks.length + '</span>' +
30
- '<span>' + tasks.length + ' ' + taskLabel + '</span></button>';
115
+ var summary = tasks.length + ' ' + taskLabel;
116
+ var html = '<button type="button" class="background-tasks-toggle" aria-expanded="' + expanded + '"' +
117
+ ' aria-controls="background-tasks-list" aria-label="' + summary + ', ' +
118
+ (expanded ? 'collapse' : 'expand') + ' details">' +
119
+ activityDotsHtml() +
120
+ '<span class="background-tasks-summary">' + summary + '</span>' +
121
+ '<span class="background-tasks-chevron" aria-hidden="true">' + iconHtml('chevron-right') + '</span>' +
122
+ '</button>';
123
+
31
124
  if (expanded) {
32
- html += '<div class="background-tasks-list">';
33
- for (var i = 0; i < tasks.length; i++) {
34
- var task = tasks[i];
35
- html += '<div class="background-task-row"><span class="background-task-description" title="' + escapeHtml(task.description || '') + '">' +
36
- escapeHtml(task.description || 'Background task') + '</span><span class="background-task-type">' +
37
- escapeHtml(task.task_type || 'other') + '</span><button type="button" class="background-task-stop" data-task-id="' +
38
- escapeHtml(task.task_id || '') + '">Stop</button></div>';
39
- }
125
+ var now = Date.now();
126
+ html += '<div class="background-tasks-list" id="background-tasks-list">';
127
+ for (var i = 0; i < tasks.length; i++) html += taskRowHtml(tasks[i], now);
40
128
  html += '</div>';
41
129
  }
42
130
  bar.innerHTML = html;
43
131
  if (!existing) inputArea.parentNode.insertBefore(bar, inputArea);
44
- bar.querySelector('.background-tasks-toggle').addEventListener('click', function() {
132
+ refreshIcons();
133
+
134
+ bar.querySelector('.background-tasks-toggle').addEventListener('click', function () {
45
135
  expanded = !expanded;
46
136
  renderBackgroundTasks();
47
137
  });
138
+
48
139
  var stopButtons = bar.querySelectorAll('.background-task-stop');
49
140
  for (var j = 0; j < stopButtons.length; j++) {
50
- stopButtons[j].addEventListener('click', function() {
141
+ stopButtons[j].addEventListener('click', function () {
51
142
  var ws = getWs();
52
143
  if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'stop_task', taskId: this.dataset.taskId }));
53
144
  });
54
145
  }
146
+
147
+ if (expanded) startElapsedTimer();
148
+ else stopElapsedTimer();
55
149
  }
@@ -23,6 +23,15 @@ export function reportPaneContext(data) {
23
23
  }, window.location.origin);
24
24
  }
25
25
 
26
+ // A pane has no banner surface and no login modal of its own, so both panes
27
+ // hitting auth_required would otherwise race to start their own login flow.
28
+ // Hand the event to the parent shell, which owns the single flow.
29
+ export function forwardPaneAuthRequired(message) {
30
+ if (!store.get('paneMode') || window.parent === window) return false;
31
+ window.parent.postMessage({ type: "clay-pane-auth-required", message: message }, window.location.origin);
32
+ return true;
33
+ }
34
+
26
35
  export function forwardPaneMarkdownPresentation(message) {
27
36
  if (!store.get('paneMode') || window.parent === window) return false;
28
37
  window.parent.postMessage({ type: "clay-pane-present-markdown", message: message }, window.location.origin);
@@ -11,6 +11,7 @@ import { groupedSessionIds, findSplitGroup } from './split-group-helpers.js';
11
11
  import { showConfirm } from './app-misc.js';
12
12
  import { syncPairChrome } from './split-pair-ui.js';
13
13
  import { presentMarkdownEdit } from './filebrowser.js';
14
+ import { autoStartLoginIfNeeded } from './vendor-login.js';
14
15
 
15
16
  var host = null;
16
17
  var nativeApp = null;
@@ -276,6 +277,12 @@ function handlePaneMessage(event) {
276
277
  if (present) presentMarkdownEdit(present);
277
278
  return;
278
279
  }
280
+ // Both panes can report auth_required for their own sessions. The shell runs
281
+ // one login flow for the project; repeat events are no-ops there.
282
+ if (msg.type === "clay-pane-auth-required") {
283
+ if (msg.message) autoStartLoginIfNeeded(msg.message);
284
+ return;
285
+ }
279
286
  if (msg.type !== "clay-pane-context") return;
280
287
  var frames = host.querySelectorAll(".split-pane-frame");
281
288
  for (var i = 0; i < frames.length; i++) {
@@ -29,6 +29,10 @@ var modalWs = null;
29
29
  var modalResizeObserver = null;
30
30
  var modalKeyHandler = null;
31
31
  var modalResizeDebounce = null;
32
+ // Optional per-open callback fired once the modal is torn down, whatever
33
+ // dismissed it (Esc, backdrop click, term_closed). The login flow uses it to
34
+ // cancel a login the user walked away from.
35
+ var modalCloseHook = null;
32
36
  // Debounced fit+redraw for the modal xterm. Same rationale as
33
37
  // session-tui-view.js: collapses rapid resize events into a single
34
38
  // SIGWINCH so claude can redraw cleanly without mid-resize corruption.
@@ -139,6 +143,7 @@ export function openTuiModal(terminalId, sourceSlug, info) {
139
143
  modalTerminalId = terminalId;
140
144
  modalSourceSlug = sourceSlug;
141
145
  var infoObj = info || {};
146
+ modalCloseHook = typeof infoObj.onClose === "function" ? infoObj.onClose : null;
142
147
  setModalBreadcrumb({
143
148
  projectIcon: infoObj.projectIcon || null,
144
149
  projectName: infoObj.projectName || sourceSlug,
@@ -232,6 +237,8 @@ export function openTuiModal(terminalId, sourceSlug, info) {
232
237
 
233
238
  export function closeTuiModal() {
234
239
  if (!modalEl) return;
240
+ var closeHook = modalCloseHook;
241
+ modalCloseHook = null;
235
242
  if (modalTerminalId != null && modalWs && modalWs.readyState === 1) {
236
243
  try { modalWs.send(JSON.stringify({ type: "term_detach", id: modalTerminalId })); } catch (e) {}
237
244
  }
@@ -247,6 +254,9 @@ export function closeTuiModal() {
247
254
  document.removeEventListener("keydown", modalKeyHandler);
248
255
  modalKeyHandler = null;
249
256
  }
257
+ if (closeHook) {
258
+ try { closeHook(); } catch (e) {}
259
+ }
250
260
  }
251
261
 
252
262
  export function isTuiModalOpen() {