@flrande/bak-extension 0.6.4 → 0.6.5

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.
@@ -1 +1 @@
1
- 2026-03-12T15:53:45.600Z
1
+ 2026-03-13T06:30:44.568Z
@@ -62,7 +62,7 @@
62
62
  // package.json
63
63
  var package_default = {
64
64
  name: "@flrande/bak-extension",
65
- version: "0.6.4",
65
+ version: "0.6.5",
66
66
  type: "module",
67
67
  scripts: {
68
68
  build: "tsup src/background.ts src/content.ts src/popup.ts --format iife --out-dir dist --clean && node scripts/copy-assets.mjs",
@@ -838,22 +838,9 @@
838
838
  await this.browser.closeTab(resolvedTabId);
839
839
  const remainingTabIds = ensured.binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
840
840
  if (remainingTabIds.length === 0) {
841
- const emptied = {
842
- id: ensured.binding.id,
843
- label: ensured.binding.label,
844
- color: ensured.binding.color,
845
- windowId: null,
846
- groupId: null,
847
- tabIds: [],
848
- activeTabId: null,
849
- primaryTabId: null
850
- };
851
- await this.storage.save(emptied);
841
+ await this.storage.delete(ensured.binding.id);
852
842
  return {
853
- binding: {
854
- ...emptied,
855
- tabs: []
856
- },
843
+ binding: null,
857
844
  closedTabId: resolvedTabId
858
845
  };
859
846
  }
@@ -1333,11 +1320,14 @@
1333
1320
  var ws = null;
1334
1321
  var reconnectTimer = null;
1335
1322
  var nextReconnectInMs = null;
1323
+ var nextReconnectAt = null;
1336
1324
  var reconnectAttempt = 0;
1337
1325
  var lastError = null;
1338
1326
  var manualDisconnect = false;
1339
1327
  var sessionBindingStateMutationQueue = Promise.resolve();
1340
1328
  var preserveHumanFocusDepth = 0;
1329
+ var lastBindingUpdateAt = null;
1330
+ var lastBindingUpdateReason = null;
1341
1331
  async function getConfig() {
1342
1332
  const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
1343
1333
  return {
@@ -1374,6 +1364,7 @@
1374
1364
  reconnectTimer = null;
1375
1365
  }
1376
1366
  nextReconnectInMs = null;
1367
+ nextReconnectAt = null;
1377
1368
  }
1378
1369
  function sendResponse(payload) {
1379
1370
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -1469,6 +1460,65 @@
1469
1460
  async function listSessionBindingStates() {
1470
1461
  return Object.values(await loadSessionBindingStateMap());
1471
1462
  }
1463
+ function summarizeSessionBindings(states) {
1464
+ const items = states.map((state) => {
1465
+ const detached = state.windowId === null || state.tabIds.length === 0;
1466
+ return {
1467
+ id: state.id,
1468
+ label: state.label,
1469
+ tabCount: state.tabIds.length,
1470
+ activeTabId: state.activeTabId,
1471
+ windowId: state.windowId,
1472
+ groupId: state.groupId,
1473
+ detached
1474
+ };
1475
+ });
1476
+ return {
1477
+ count: items.length,
1478
+ attachedCount: items.filter((item) => !item.detached).length,
1479
+ detachedCount: items.filter((item) => item.detached).length,
1480
+ tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
1481
+ items
1482
+ };
1483
+ }
1484
+ async function buildPopupState() {
1485
+ const config = await getConfig();
1486
+ const sessionBindings = summarizeSessionBindings(await listSessionBindingStates());
1487
+ const reconnectRemainingMs = nextReconnectAt === null ? null : Math.max(0, nextReconnectAt - Date.now());
1488
+ let connectionState;
1489
+ if (!config.token) {
1490
+ connectionState = "missing-token";
1491
+ } else if (ws?.readyState === WebSocket.OPEN) {
1492
+ connectionState = "connected";
1493
+ } else if (ws?.readyState === WebSocket.CONNECTING) {
1494
+ connectionState = "connecting";
1495
+ } else if (manualDisconnect) {
1496
+ connectionState = "manual";
1497
+ } else if (nextReconnectInMs !== null) {
1498
+ connectionState = "reconnecting";
1499
+ } else {
1500
+ connectionState = "disconnected";
1501
+ }
1502
+ return {
1503
+ ok: true,
1504
+ connected: ws?.readyState === WebSocket.OPEN,
1505
+ connectionState,
1506
+ hasToken: Boolean(config.token),
1507
+ port: config.port,
1508
+ wsUrl: `ws://127.0.0.1:${config.port}/extension`,
1509
+ debugRichText: config.debugRichText,
1510
+ lastError: lastError?.message ?? null,
1511
+ lastErrorAt: lastError?.at ?? null,
1512
+ lastErrorContext: lastError?.context ?? null,
1513
+ reconnectAttempt,
1514
+ nextReconnectInMs: reconnectRemainingMs,
1515
+ manualDisconnect,
1516
+ extensionVersion: EXTENSION_VERSION,
1517
+ lastBindingUpdateAt,
1518
+ lastBindingUpdateReason,
1519
+ sessionBindings
1520
+ };
1521
+ }
1472
1522
  async function saveSessionBindingState(state) {
1473
1523
  await mutateSessionBindingStateMap((stateMap) => {
1474
1524
  stateMap[state.id] = state;
@@ -1492,6 +1542,8 @@
1492
1542
  };
1493
1543
  }
1494
1544
  function emitSessionBindingUpdated(bindingId, reason, state, extras = {}) {
1545
+ lastBindingUpdateAt = Date.now();
1546
+ lastBindingUpdateReason = reason;
1495
1547
  sendEvent("sessionBinding.updated", {
1496
1548
  bindingId,
1497
1549
  reason,
@@ -3311,9 +3363,11 @@
3311
3363
  const delayMs = computeReconnectDelayMs(reconnectAttempt);
3312
3364
  reconnectAttempt += 1;
3313
3365
  nextReconnectInMs = delayMs;
3366
+ nextReconnectAt = Date.now() + delayMs;
3314
3367
  reconnectTimer = setTimeout(() => {
3315
3368
  reconnectTimer = null;
3316
3369
  nextReconnectInMs = null;
3370
+ nextReconnectAt = null;
3317
3371
  void connectWebSocket();
3318
3372
  }, delayMs);
3319
3373
  if (!lastError) {
@@ -3334,19 +3388,23 @@
3334
3388
  return;
3335
3389
  }
3336
3390
  const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
3337
- ws = new WebSocket(url);
3338
- ws.addEventListener("open", () => {
3391
+ const socket = new WebSocket(url);
3392
+ ws = socket;
3393
+ socket.addEventListener("open", () => {
3394
+ if (ws !== socket) {
3395
+ return;
3396
+ }
3339
3397
  manualDisconnect = false;
3340
3398
  reconnectAttempt = 0;
3341
3399
  lastError = null;
3342
- ws?.send(JSON.stringify({
3400
+ socket.send(JSON.stringify({
3343
3401
  type: "hello",
3344
3402
  role: "extension",
3345
3403
  version: EXTENSION_VERSION,
3346
3404
  ts: Date.now()
3347
3405
  }));
3348
3406
  });
3349
- ws.addEventListener("message", (event) => {
3407
+ socket.addEventListener("message", (event) => {
3350
3408
  try {
3351
3409
  const request = JSON.parse(String(event.data));
3352
3410
  if (!request.id || !request.method) {
@@ -3367,13 +3425,19 @@
3367
3425
  });
3368
3426
  }
3369
3427
  });
3370
- ws.addEventListener("close", () => {
3428
+ socket.addEventListener("close", () => {
3429
+ if (ws !== socket) {
3430
+ return;
3431
+ }
3371
3432
  ws = null;
3372
3433
  scheduleReconnect("socket-closed");
3373
3434
  });
3374
- ws.addEventListener("error", () => {
3435
+ socket.addEventListener("error", () => {
3436
+ if (ws !== socket) {
3437
+ return;
3438
+ }
3375
3439
  setRuntimeError("Cannot connect to bak cli", "socket");
3376
- ws?.close();
3440
+ socket.close();
3377
3441
  });
3378
3442
  }
3379
3443
  chrome.tabs.onRemoved.addListener((tabId) => {
@@ -3385,6 +3449,11 @@
3385
3449
  continue;
3386
3450
  }
3387
3451
  const nextTabIds = state.tabIds.filter((id) => id !== tabId);
3452
+ if (nextTabIds.length === 0) {
3453
+ delete stateMap[bindingId];
3454
+ updates.push({ bindingId, state: null });
3455
+ continue;
3456
+ }
3388
3457
  const fallbackTabId = nextTabIds[0] ?? null;
3389
3458
  const nextState = {
3390
3459
  ...state,
@@ -3440,16 +3509,8 @@
3440
3509
  if (state.windowId !== windowId) {
3441
3510
  continue;
3442
3511
  }
3443
- const nextState = {
3444
- ...state,
3445
- windowId: null,
3446
- groupId: null,
3447
- tabIds: [],
3448
- activeTabId: null,
3449
- primaryTabId: null
3450
- };
3451
- stateMap[bindingId] = nextState;
3452
- updates.push({ bindingId, state: nextState });
3512
+ delete stateMap[bindingId];
3513
+ updates.push({ bindingId, state: null });
3453
3514
  }
3454
3515
  return updates;
3455
3516
  }).then((updates) => {
@@ -3468,8 +3529,9 @@
3468
3529
  chrome.runtime.onMessage.addListener((message, _sender, sendResponse2) => {
3469
3530
  if (message?.type === "bak.updateConfig") {
3470
3531
  manualDisconnect = false;
3532
+ const token = typeof message.token === "string" ? message.token.trim() : "";
3471
3533
  void setConfig({
3472
- token: message.token,
3534
+ ...token ? { token } : {},
3473
3535
  port: Number(message.port ?? DEFAULT_PORT),
3474
3536
  debugRichText: message.debugRichText === true
3475
3537
  }).then(() => {
@@ -3479,19 +3541,8 @@
3479
3541
  return true;
3480
3542
  }
3481
3543
  if (message?.type === "bak.getState") {
3482
- void getConfig().then((config) => {
3483
- sendResponse2({
3484
- ok: true,
3485
- connected: ws?.readyState === WebSocket.OPEN,
3486
- hasToken: Boolean(config.token),
3487
- port: config.port,
3488
- debugRichText: config.debugRichText,
3489
- lastError: lastError?.message ?? null,
3490
- lastErrorAt: lastError?.at ?? null,
3491
- lastErrorContext: lastError?.context ?? null,
3492
- reconnectAttempt,
3493
- nextReconnectInMs
3494
- });
3544
+ void buildPopupState().then((state) => {
3545
+ sendResponse2(state);
3495
3546
  });
3496
3547
  return true;
3497
3548
  }
@@ -3499,11 +3550,21 @@
3499
3550
  manualDisconnect = true;
3500
3551
  clearReconnectTimer();
3501
3552
  reconnectAttempt = 0;
3553
+ lastError = null;
3502
3554
  ws?.close();
3503
3555
  ws = null;
3504
3556
  sendResponse2({ ok: true });
3505
3557
  return false;
3506
3558
  }
3559
+ if (message?.type === "bak.reconnectNow") {
3560
+ manualDisconnect = false;
3561
+ clearReconnectTimer();
3562
+ reconnectAttempt = 0;
3563
+ ws?.close();
3564
+ ws = null;
3565
+ void connectWebSocket().then(() => sendResponse2({ ok: true }));
3566
+ return true;
3567
+ }
3507
3568
  return false;
3508
3569
  });
3509
3570
  })();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Agent Kit",
4
- "version": "0.6.4",
4
+ "version": "0.6.5",
5
5
  "action": {
6
6
  "default_popup": "popup.html"
7
7
  },
@@ -6,18 +6,97 @@
6
6
  var portInput = document.getElementById("port");
7
7
  var debugRichTextInput = document.getElementById("debugRichText");
8
8
  var saveBtn = document.getElementById("save");
9
+ var reconnectBtn = document.getElementById("reconnect");
9
10
  var disconnectBtn = document.getElementById("disconnect");
11
+ var connectionStateEl = document.getElementById("connectionState");
12
+ var tokenStateEl = document.getElementById("tokenState");
13
+ var reconnectStateEl = document.getElementById("reconnectState");
14
+ var connectionUrlEl = document.getElementById("connectionUrl");
15
+ var lastErrorEl = document.getElementById("lastError");
16
+ var lastBindingUpdateEl = document.getElementById("lastBindingUpdate");
17
+ var extensionVersionEl = document.getElementById("extensionVersion");
18
+ var sessionSummaryEl = document.getElementById("sessionSummary");
19
+ var sessionListEl = document.getElementById("sessionList");
20
+ var latestState = null;
10
21
  function setStatus(text, bad = false) {
11
22
  statusEl.textContent = text;
12
23
  statusEl.style.color = bad ? "#dc2626" : "#0f172a";
13
24
  }
25
+ function formatTimeAgo(at) {
26
+ if (typeof at !== "number") {
27
+ return "never";
28
+ }
29
+ const deltaSeconds = Math.max(0, Math.round((Date.now() - at) / 1e3));
30
+ if (deltaSeconds < 5) {
31
+ return "just now";
32
+ }
33
+ if (deltaSeconds < 60) {
34
+ return `${deltaSeconds}s ago`;
35
+ }
36
+ const deltaMinutes = Math.round(deltaSeconds / 60);
37
+ if (deltaMinutes < 60) {
38
+ return `${deltaMinutes}m ago`;
39
+ }
40
+ const deltaHours = Math.round(deltaMinutes / 60);
41
+ return `${deltaHours}h ago`;
42
+ }
43
+ function renderSessionBindings(state) {
44
+ sessionSummaryEl.textContent = `${state.count} sessions, ${state.attachedCount} attached, ${state.tabCount} tabs, ${state.detachedCount} detached`;
45
+ sessionListEl.replaceChildren();
46
+ for (const item of state.items) {
47
+ const li = document.createElement("li");
48
+ const location = item.windowId === null ? "no window" : `window ${item.windowId}`;
49
+ const active = item.activeTabId === null ? "no active tab" : `active ${item.activeTabId}`;
50
+ li.textContent = `${item.label}: ${item.tabCount} tabs, ${location}, ${active}`;
51
+ if (item.detached) {
52
+ li.style.color = "#b45309";
53
+ }
54
+ sessionListEl.appendChild(li);
55
+ }
56
+ }
57
+ function renderConnectionDetails(state) {
58
+ connectionStateEl.textContent = state.connectionState;
59
+ tokenStateEl.textContent = state.hasToken ? "configured" : "missing";
60
+ connectionUrlEl.textContent = state.wsUrl;
61
+ extensionVersionEl.textContent = state.extensionVersion;
62
+ if (state.manualDisconnect) {
63
+ reconnectStateEl.textContent = "manual disconnect";
64
+ } else if (typeof state.nextReconnectInMs === "number") {
65
+ const seconds = Math.max(0, Math.ceil(state.nextReconnectInMs / 100) / 10);
66
+ reconnectStateEl.textContent = `attempt ${state.reconnectAttempt}, retry in ${seconds}s`;
67
+ } else if (state.connected) {
68
+ reconnectStateEl.textContent = "connected";
69
+ } else {
70
+ reconnectStateEl.textContent = "idle";
71
+ }
72
+ if (state.lastError) {
73
+ const context = state.lastErrorContext ? `${state.lastErrorContext}: ` : "";
74
+ lastErrorEl.textContent = `${context}${state.lastError} (${formatTimeAgo(state.lastErrorAt)})`;
75
+ } else {
76
+ lastErrorEl.textContent = "none";
77
+ }
78
+ if (state.lastBindingUpdateReason) {
79
+ lastBindingUpdateEl.textContent = `${state.lastBindingUpdateReason} (${formatTimeAgo(state.lastBindingUpdateAt)})`;
80
+ } else {
81
+ lastBindingUpdateEl.textContent = "none";
82
+ }
83
+ }
14
84
  async function refreshState() {
15
85
  const state = await chrome.runtime.sendMessage({ type: "bak.getState" });
16
86
  if (state.ok) {
87
+ latestState = state;
17
88
  portInput.value = String(state.port);
18
89
  debugRichTextInput.checked = Boolean(state.debugRichText);
90
+ renderConnectionDetails(state);
91
+ renderSessionBindings(state.sessionBindings);
19
92
  if (state.connected) {
20
93
  setStatus("Connected to bak CLI");
94
+ } else if (state.connectionState === "missing-token") {
95
+ setStatus("Pair token is required", true);
96
+ } else if (state.connectionState === "manual") {
97
+ setStatus("Disconnected manually");
98
+ } else if (state.connectionState === "reconnecting") {
99
+ setStatus("Reconnecting to bak CLI", true);
21
100
  } else if (state.lastError) {
22
101
  setStatus(`Disconnected: ${state.lastError}`, true);
23
102
  } else {
@@ -28,7 +107,7 @@
28
107
  saveBtn.addEventListener("click", async () => {
29
108
  const token = tokenInput.value.trim();
30
109
  const port = Number.parseInt(portInput.value.trim(), 10);
31
- if (!token) {
110
+ if (!token && latestState?.hasToken !== true) {
32
111
  setStatus("Pair token is required", true);
33
112
  return;
34
113
  }
@@ -38,10 +117,15 @@
38
117
  }
39
118
  await chrome.runtime.sendMessage({
40
119
  type: "bak.updateConfig",
41
- token,
120
+ ...token ? { token } : {},
42
121
  port,
43
122
  debugRichText: debugRichTextInput.checked
44
123
  });
124
+ tokenInput.value = "";
125
+ await refreshState();
126
+ });
127
+ reconnectBtn.addEventListener("click", async () => {
128
+ await chrome.runtime.sendMessage({ type: "bak.reconnectNow" });
45
129
  await refreshState();
46
130
  });
47
131
  disconnectBtn.addEventListener("click", async () => {
@@ -49,4 +133,10 @@
49
133
  await refreshState();
50
134
  });
51
135
  void refreshState();
136
+ var refreshInterval = window.setInterval(() => {
137
+ void refreshState();
138
+ }, 1e3);
139
+ window.addEventListener("unload", () => {
140
+ window.clearInterval(refreshInterval);
141
+ });
52
142
  })();
package/dist/popup.html CHANGED
@@ -70,9 +70,50 @@
70
70
  background: #e2e8f0;
71
71
  color: #0f172a;
72
72
  }
73
+ #reconnect {
74
+ background: #dbeafe;
75
+ color: #1d4ed8;
76
+ }
73
77
  #status {
74
78
  margin-top: 10px;
75
79
  font-size: 12px;
80
+ font-weight: 600;
81
+ }
82
+ .panel {
83
+ margin-top: 12px;
84
+ padding: 10px;
85
+ border: 1px solid #cbd5e1;
86
+ border-radius: 8px;
87
+ background: rgba(255, 255, 255, 0.8);
88
+ }
89
+ .panel h2 {
90
+ margin: 0 0 8px;
91
+ font-size: 12px;
92
+ }
93
+ .meta-grid {
94
+ display: grid;
95
+ grid-template-columns: auto 1fr;
96
+ gap: 6px 10px;
97
+ font-size: 11px;
98
+ }
99
+ .meta-grid dt {
100
+ color: #475569;
101
+ }
102
+ .meta-grid dd {
103
+ margin: 0;
104
+ color: #0f172a;
105
+ word-break: break-word;
106
+ }
107
+ #sessionList {
108
+ margin: 0;
109
+ padding-left: 16px;
110
+ font-size: 11px;
111
+ color: #0f172a;
112
+ }
113
+ #sessionList:empty::before {
114
+ content: "No tracked sessions";
115
+ color: #64748b;
116
+ margin-left: -16px;
76
117
  }
77
118
  .hint {
78
119
  margin-top: 10px;
@@ -85,7 +126,7 @@
85
126
  <h1>Browser Agent Kit</h1>
86
127
  <label>
87
128
  Pair token
88
- <input id="token" placeholder="paste token from `bak pair`" />
129
+ <input id="token" placeholder="paste token from `bak pair` or leave blank to keep the saved token" />
89
130
  </label>
90
131
  <label>
91
132
  CLI port
@@ -97,9 +138,39 @@
97
138
  </label>
98
139
  <div class="row">
99
140
  <button id="save">Save & Connect</button>
141
+ <button id="reconnect">Reconnect</button>
142
+ </div>
143
+ <div class="row">
100
144
  <button id="disconnect">Disconnect</button>
101
145
  </div>
102
146
  <div id="status">Checking...</div>
147
+ <div class="panel">
148
+ <h2>Connection</h2>
149
+ <dl class="meta-grid">
150
+ <dt>State</dt>
151
+ <dd id="connectionState">-</dd>
152
+ <dt>Token</dt>
153
+ <dd id="tokenState">-</dd>
154
+ <dt>Reconnect</dt>
155
+ <dd id="reconnectState">-</dd>
156
+ <dt>CLI URL</dt>
157
+ <dd id="connectionUrl">-</dd>
158
+ <dt>Last error</dt>
159
+ <dd id="lastError">-</dd>
160
+ <dt>Last binding</dt>
161
+ <dd id="lastBindingUpdate">-</dd>
162
+ <dt>Extension</dt>
163
+ <dd id="extensionVersion">-</dd>
164
+ </dl>
165
+ </div>
166
+ <div class="panel">
167
+ <h2>Sessions</h2>
168
+ <dl class="meta-grid">
169
+ <dt>Tracked</dt>
170
+ <dd id="sessionSummary">-</dd>
171
+ </dl>
172
+ <ul id="sessionList"></ul>
173
+ </div>
103
174
  <div class="hint">Extension only connects to ws://127.0.0.1</div>
104
175
  <script src="./popup.global.js"></script>
105
176
  </body>
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@flrande/bak-extension",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@flrande/bak-protocol": "0.6.4"
6
+ "@flrande/bak-protocol": "0.6.5"
7
7
  },
8
8
  "devDependencies": {
9
9
  "@types/chrome": "^0.1.14",
package/public/popup.html CHANGED
@@ -70,9 +70,50 @@
70
70
  background: #e2e8f0;
71
71
  color: #0f172a;
72
72
  }
73
+ #reconnect {
74
+ background: #dbeafe;
75
+ color: #1d4ed8;
76
+ }
73
77
  #status {
74
78
  margin-top: 10px;
75
79
  font-size: 12px;
80
+ font-weight: 600;
81
+ }
82
+ .panel {
83
+ margin-top: 12px;
84
+ padding: 10px;
85
+ border: 1px solid #cbd5e1;
86
+ border-radius: 8px;
87
+ background: rgba(255, 255, 255, 0.8);
88
+ }
89
+ .panel h2 {
90
+ margin: 0 0 8px;
91
+ font-size: 12px;
92
+ }
93
+ .meta-grid {
94
+ display: grid;
95
+ grid-template-columns: auto 1fr;
96
+ gap: 6px 10px;
97
+ font-size: 11px;
98
+ }
99
+ .meta-grid dt {
100
+ color: #475569;
101
+ }
102
+ .meta-grid dd {
103
+ margin: 0;
104
+ color: #0f172a;
105
+ word-break: break-word;
106
+ }
107
+ #sessionList {
108
+ margin: 0;
109
+ padding-left: 16px;
110
+ font-size: 11px;
111
+ color: #0f172a;
112
+ }
113
+ #sessionList:empty::before {
114
+ content: "No tracked sessions";
115
+ color: #64748b;
116
+ margin-left: -16px;
76
117
  }
77
118
  .hint {
78
119
  margin-top: 10px;
@@ -85,7 +126,7 @@
85
126
  <h1>Browser Agent Kit</h1>
86
127
  <label>
87
128
  Pair token
88
- <input id="token" placeholder="paste token from `bak pair`" />
129
+ <input id="token" placeholder="paste token from `bak pair` or leave blank to keep the saved token" />
89
130
  </label>
90
131
  <label>
91
132
  CLI port
@@ -97,9 +138,39 @@
97
138
  </label>
98
139
  <div class="row">
99
140
  <button id="save">Save & Connect</button>
141
+ <button id="reconnect">Reconnect</button>
142
+ </div>
143
+ <div class="row">
100
144
  <button id="disconnect">Disconnect</button>
101
145
  </div>
102
146
  <div id="status">Checking...</div>
147
+ <div class="panel">
148
+ <h2>Connection</h2>
149
+ <dl class="meta-grid">
150
+ <dt>State</dt>
151
+ <dd id="connectionState">-</dd>
152
+ <dt>Token</dt>
153
+ <dd id="tokenState">-</dd>
154
+ <dt>Reconnect</dt>
155
+ <dd id="reconnectState">-</dd>
156
+ <dt>CLI URL</dt>
157
+ <dd id="connectionUrl">-</dd>
158
+ <dt>Last error</dt>
159
+ <dd id="lastError">-</dd>
160
+ <dt>Last binding</dt>
161
+ <dd id="lastBindingUpdate">-</dd>
162
+ <dt>Extension</dt>
163
+ <dd id="extensionVersion">-</dd>
164
+ </dl>
165
+ </div>
166
+ <div class="panel">
167
+ <h2>Sessions</h2>
168
+ <dl class="meta-grid">
169
+ <dt>Tracked</dt>
170
+ <dd id="sessionSummary">-</dd>
171
+ </dl>
172
+ <ul id="sessionList"></ul>
173
+ </div>
103
174
  <div class="hint">Extension only connects to ws://127.0.0.1</div>
104
175
  <script src="./popup.global.js"></script>
105
176
  </body>
package/src/background.ts CHANGED
@@ -59,11 +59,47 @@ interface ExtensionConfig {
59
59
  debugRichText: boolean;
60
60
  }
61
61
 
62
- interface RuntimeErrorDetails {
63
- message: string;
64
- context: 'config' | 'socket' | 'request' | 'parse';
65
- at: number;
66
- }
62
+ interface RuntimeErrorDetails {
63
+ message: string;
64
+ context: 'config' | 'socket' | 'request' | 'parse';
65
+ at: number;
66
+ }
67
+
68
+ interface PopupSessionBindingSummary {
69
+ id: string;
70
+ label: string;
71
+ tabCount: number;
72
+ activeTabId: number | null;
73
+ windowId: number | null;
74
+ groupId: number | null;
75
+ detached: boolean;
76
+ }
77
+
78
+ interface PopupState {
79
+ ok: true;
80
+ connected: boolean;
81
+ connectionState: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'manual' | 'missing-token';
82
+ hasToken: boolean;
83
+ port: number;
84
+ wsUrl: string;
85
+ debugRichText: boolean;
86
+ lastError: string | null;
87
+ lastErrorAt: number | null;
88
+ lastErrorContext: RuntimeErrorDetails['context'] | null;
89
+ reconnectAttempt: number;
90
+ nextReconnectInMs: number | null;
91
+ manualDisconnect: boolean;
92
+ extensionVersion: string;
93
+ lastBindingUpdateAt: number | null;
94
+ lastBindingUpdateReason: string | null;
95
+ sessionBindings: {
96
+ count: number;
97
+ attachedCount: number;
98
+ detachedCount: number;
99
+ tabCount: number;
100
+ items: PopupSessionBindingSummary[];
101
+ };
102
+ }
67
103
 
68
104
  const DEFAULT_PORT = 17373;
69
105
  const STORAGE_KEY_TOKEN = 'pairToken';
@@ -112,11 +148,14 @@ const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
112
148
  let ws: WebSocket | null = null;
113
149
  let reconnectTimer: number | null = null;
114
150
  let nextReconnectInMs: number | null = null;
151
+ let nextReconnectAt: number | null = null;
115
152
  let reconnectAttempt = 0;
116
153
  let lastError: RuntimeErrorDetails | null = null;
117
154
  let manualDisconnect = false;
118
155
  let sessionBindingStateMutationQueue: Promise<void> = Promise.resolve();
119
156
  let preserveHumanFocusDepth = 0;
157
+ let lastBindingUpdateAt: number | null = null;
158
+ let lastBindingUpdateReason: string | null = null;
120
159
 
121
160
  async function getConfig(): Promise<ExtensionConfig> {
122
161
  const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
@@ -151,13 +190,14 @@ function setRuntimeError(message: string, context: RuntimeErrorDetails['context'
151
190
  };
152
191
  }
153
192
 
154
- function clearReconnectTimer(): void {
155
- if (reconnectTimer !== null) {
156
- clearTimeout(reconnectTimer);
157
- reconnectTimer = null;
158
- }
159
- nextReconnectInMs = null;
160
- }
193
+ function clearReconnectTimer(): void {
194
+ if (reconnectTimer !== null) {
195
+ clearTimeout(reconnectTimer);
196
+ reconnectTimer = null;
197
+ }
198
+ nextReconnectInMs = null;
199
+ nextReconnectAt = null;
200
+ }
161
201
 
162
202
  function sendResponse(payload: CliResponse): void {
163
203
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -264,9 +304,71 @@ async function loadSessionBindingState(bindingId: string): Promise<SessionBindin
264
304
  return stateMap[bindingId] ?? null;
265
305
  }
266
306
 
267
- async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
268
- return Object.values(await loadSessionBindingStateMap());
269
- }
307
+ async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
308
+ return Object.values(await loadSessionBindingStateMap());
309
+ }
310
+
311
+ function summarizeSessionBindings(states: SessionBindingRecord[]): PopupState['sessionBindings'] {
312
+ const items = states.map((state) => {
313
+ const detached = state.windowId === null || state.tabIds.length === 0;
314
+ return {
315
+ id: state.id,
316
+ label: state.label,
317
+ tabCount: state.tabIds.length,
318
+ activeTabId: state.activeTabId,
319
+ windowId: state.windowId,
320
+ groupId: state.groupId,
321
+ detached
322
+ } satisfies PopupSessionBindingSummary;
323
+ });
324
+ return {
325
+ count: items.length,
326
+ attachedCount: items.filter((item) => !item.detached).length,
327
+ detachedCount: items.filter((item) => item.detached).length,
328
+ tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
329
+ items
330
+ };
331
+ }
332
+
333
+ async function buildPopupState(): Promise<PopupState> {
334
+ const config = await getConfig();
335
+ const sessionBindings = summarizeSessionBindings(await listSessionBindingStates());
336
+ const reconnectRemainingMs = nextReconnectAt === null ? null : Math.max(0, nextReconnectAt - Date.now());
337
+ let connectionState: PopupState['connectionState'];
338
+ if (!config.token) {
339
+ connectionState = 'missing-token';
340
+ } else if (ws?.readyState === WebSocket.OPEN) {
341
+ connectionState = 'connected';
342
+ } else if (ws?.readyState === WebSocket.CONNECTING) {
343
+ connectionState = 'connecting';
344
+ } else if (manualDisconnect) {
345
+ connectionState = 'manual';
346
+ } else if (nextReconnectInMs !== null) {
347
+ connectionState = 'reconnecting';
348
+ } else {
349
+ connectionState = 'disconnected';
350
+ }
351
+
352
+ return {
353
+ ok: true,
354
+ connected: ws?.readyState === WebSocket.OPEN,
355
+ connectionState,
356
+ hasToken: Boolean(config.token),
357
+ port: config.port,
358
+ wsUrl: `ws://127.0.0.1:${config.port}/extension`,
359
+ debugRichText: config.debugRichText,
360
+ lastError: lastError?.message ?? null,
361
+ lastErrorAt: lastError?.at ?? null,
362
+ lastErrorContext: lastError?.context ?? null,
363
+ reconnectAttempt,
364
+ nextReconnectInMs: reconnectRemainingMs,
365
+ manualDisconnect,
366
+ extensionVersion: EXTENSION_VERSION,
367
+ lastBindingUpdateAt,
368
+ lastBindingUpdateReason,
369
+ sessionBindings
370
+ };
371
+ }
270
372
 
271
373
  async function saveSessionBindingState(state: SessionBindingRecord): Promise<void> {
272
374
  await mutateSessionBindingStateMap((stateMap) => {
@@ -299,6 +401,8 @@ function emitSessionBindingUpdated(
299
401
  state: SessionBindingRecord | null,
300
402
  extras: Record<string, unknown> = {}
301
403
  ): void {
404
+ lastBindingUpdateAt = Date.now();
405
+ lastBindingUpdateReason = reason;
302
406
  sendEvent('sessionBinding.updated', {
303
407
  bindingId,
304
408
  reason,
@@ -2336,14 +2440,16 @@ function scheduleReconnect(reason: string): void {
2336
2440
  return;
2337
2441
  }
2338
2442
 
2339
- const delayMs = computeReconnectDelayMs(reconnectAttempt);
2340
- reconnectAttempt += 1;
2341
- nextReconnectInMs = delayMs;
2342
- reconnectTimer = setTimeout(() => {
2343
- reconnectTimer = null;
2344
- nextReconnectInMs = null;
2345
- void connectWebSocket();
2346
- }, delayMs) as unknown as number;
2443
+ const delayMs = computeReconnectDelayMs(reconnectAttempt);
2444
+ reconnectAttempt += 1;
2445
+ nextReconnectInMs = delayMs;
2446
+ nextReconnectAt = Date.now() + delayMs;
2447
+ reconnectTimer = setTimeout(() => {
2448
+ reconnectTimer = null;
2449
+ nextReconnectInMs = null;
2450
+ nextReconnectAt = null;
2451
+ void connectWebSocket();
2452
+ }, delayMs) as unknown as number;
2347
2453
 
2348
2454
  if (!lastError) {
2349
2455
  setRuntimeError(`Reconnect scheduled: ${reason}`, 'socket');
@@ -2366,26 +2472,30 @@ async function connectWebSocket(): Promise<void> {
2366
2472
  return;
2367
2473
  }
2368
2474
 
2369
- const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
2370
- ws = new WebSocket(url);
2371
-
2372
- ws.addEventListener('open', () => {
2373
- manualDisconnect = false;
2374
- reconnectAttempt = 0;
2375
- lastError = null;
2376
- ws?.send(JSON.stringify({
2475
+ const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
2476
+ const socket = new WebSocket(url);
2477
+ ws = socket;
2478
+
2479
+ socket.addEventListener('open', () => {
2480
+ if (ws !== socket) {
2481
+ return;
2482
+ }
2483
+ manualDisconnect = false;
2484
+ reconnectAttempt = 0;
2485
+ lastError = null;
2486
+ socket.send(JSON.stringify({
2377
2487
  type: 'hello',
2378
2488
  role: 'extension',
2379
2489
  version: EXTENSION_VERSION,
2380
2490
  ts: Date.now()
2381
2491
  }));
2382
2492
  });
2383
-
2384
- ws.addEventListener('message', (event) => {
2385
- try {
2386
- const request = JSON.parse(String(event.data)) as CliRequest;
2387
- if (!request.id || !request.method) {
2388
- return;
2493
+
2494
+ socket.addEventListener('message', (event) => {
2495
+ try {
2496
+ const request = JSON.parse(String(event.data)) as CliRequest;
2497
+ if (!request.id || !request.method) {
2498
+ return;
2389
2499
  }
2390
2500
  void handleRequest(request)
2391
2501
  .then((result) => {
@@ -2402,29 +2512,40 @@ async function connectWebSocket(): Promise<void> {
2402
2512
  ok: false,
2403
2513
  error: toError('E_INTERNAL', error instanceof Error ? error.message : String(error))
2404
2514
  });
2405
- }
2406
- });
2407
-
2408
- ws.addEventListener('close', () => {
2409
- ws = null;
2410
- scheduleReconnect('socket-closed');
2411
- });
2412
-
2413
- ws.addEventListener('error', () => {
2414
- setRuntimeError('Cannot connect to bak cli', 'socket');
2415
- ws?.close();
2416
- });
2417
- }
2515
+ }
2516
+ });
2517
+
2518
+ socket.addEventListener('close', () => {
2519
+ if (ws !== socket) {
2520
+ return;
2521
+ }
2522
+ ws = null;
2523
+ scheduleReconnect('socket-closed');
2524
+ });
2525
+
2526
+ socket.addEventListener('error', () => {
2527
+ if (ws !== socket) {
2528
+ return;
2529
+ }
2530
+ setRuntimeError('Cannot connect to bak cli', 'socket');
2531
+ socket.close();
2532
+ });
2533
+ }
2418
2534
 
2419
2535
  chrome.tabs.onRemoved.addListener((tabId) => {
2420
2536
  dropNetworkCapture(tabId);
2421
2537
  void mutateSessionBindingStateMap((stateMap) => {
2422
- const updates: Array<{ bindingId: string; state: SessionBindingRecord }> = [];
2538
+ const updates: Array<{ bindingId: string; state: SessionBindingRecord | null }> = [];
2423
2539
  for (const [bindingId, state] of Object.entries(stateMap)) {
2424
2540
  if (!state.tabIds.includes(tabId)) {
2425
2541
  continue;
2426
2542
  }
2427
2543
  const nextTabIds = state.tabIds.filter((id) => id !== tabId);
2544
+ if (nextTabIds.length === 0) {
2545
+ delete stateMap[bindingId];
2546
+ updates.push({ bindingId, state: null });
2547
+ continue;
2548
+ }
2428
2549
  const fallbackTabId = nextTabIds[0] ?? null;
2429
2550
  const nextState: SessionBindingRecord = {
2430
2551
  ...state,
@@ -2482,21 +2603,13 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
2482
2603
 
2483
2604
  chrome.windows.onRemoved.addListener((windowId) => {
2484
2605
  void mutateSessionBindingStateMap((stateMap) => {
2485
- const updates: Array<{ bindingId: string; state: SessionBindingRecord }> = [];
2606
+ const updates: Array<{ bindingId: string; state: SessionBindingRecord | null }> = [];
2486
2607
  for (const [bindingId, state] of Object.entries(stateMap)) {
2487
2608
  if (state.windowId !== windowId) {
2488
2609
  continue;
2489
2610
  }
2490
- const nextState: SessionBindingRecord = {
2491
- ...state,
2492
- windowId: null,
2493
- groupId: null,
2494
- tabIds: [],
2495
- activeTabId: null,
2496
- primaryTabId: null
2497
- };
2498
- stateMap[bindingId] = nextState;
2499
- updates.push({ bindingId, state: nextState });
2611
+ delete stateMap[bindingId];
2612
+ updates.push({ bindingId, state: null });
2500
2613
  }
2501
2614
  return updates;
2502
2615
  }).then((updates) => {
@@ -2517,48 +2630,49 @@ chrome.runtime.onStartup.addListener(() => {
2517
2630
  void connectWebSocket();
2518
2631
 
2519
2632
  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
2520
- if (message?.type === 'bak.updateConfig') {
2521
- manualDisconnect = false;
2522
- void setConfig({
2523
- token: message.token,
2524
- port: Number(message.port ?? DEFAULT_PORT),
2525
- debugRichText: message.debugRichText === true
2526
- }).then(() => {
2527
- ws?.close();
2528
- void connectWebSocket().then(() => sendResponse({ ok: true }));
2529
- });
2530
- return true;
2531
- }
2532
-
2533
- if (message?.type === 'bak.getState') {
2534
- void getConfig().then((config) => {
2535
- sendResponse({
2536
- ok: true,
2537
- connected: ws?.readyState === WebSocket.OPEN,
2538
- hasToken: Boolean(config.token),
2539
- port: config.port,
2540
- debugRichText: config.debugRichText,
2541
- lastError: lastError?.message ?? null,
2542
- lastErrorAt: lastError?.at ?? null,
2543
- lastErrorContext: lastError?.context ?? null,
2544
- reconnectAttempt,
2545
- nextReconnectInMs
2546
- });
2547
- });
2548
- return true;
2549
- }
2550
-
2551
- if (message?.type === 'bak.disconnect') {
2552
- manualDisconnect = true;
2553
- clearReconnectTimer();
2554
- reconnectAttempt = 0;
2555
- ws?.close();
2556
- ws = null;
2557
- sendResponse({ ok: true });
2558
- return false;
2559
- }
2633
+ if (message?.type === 'bak.updateConfig') {
2634
+ manualDisconnect = false;
2635
+ const token = typeof message.token === 'string' ? message.token.trim() : '';
2636
+ void setConfig({
2637
+ ...(token ? { token } : {}),
2638
+ port: Number(message.port ?? DEFAULT_PORT),
2639
+ debugRichText: message.debugRichText === true
2640
+ }).then(() => {
2641
+ ws?.close();
2642
+ void connectWebSocket().then(() => sendResponse({ ok: true }));
2643
+ });
2644
+ return true;
2645
+ }
2560
2646
 
2561
- return false;
2562
- });
2647
+ if (message?.type === 'bak.getState') {
2648
+ void buildPopupState().then((state) => {
2649
+ sendResponse(state);
2650
+ });
2651
+ return true;
2652
+ }
2653
+
2654
+ if (message?.type === 'bak.disconnect') {
2655
+ manualDisconnect = true;
2656
+ clearReconnectTimer();
2657
+ reconnectAttempt = 0;
2658
+ lastError = null;
2659
+ ws?.close();
2660
+ ws = null;
2661
+ sendResponse({ ok: true });
2662
+ return false;
2663
+ }
2664
+
2665
+ if (message?.type === 'bak.reconnectNow') {
2666
+ manualDisconnect = false;
2667
+ clearReconnectTimer();
2668
+ reconnectAttempt = 0;
2669
+ ws?.close();
2670
+ ws = null;
2671
+ void connectWebSocket().then(() => sendResponse({ ok: true }));
2672
+ return true;
2673
+ }
2674
+
2675
+ return false;
2676
+ });
2563
2677
 
2564
2678
 
package/src/popup.ts CHANGED
@@ -3,28 +3,140 @@ const tokenInput = document.getElementById('token') as HTMLInputElement;
3
3
  const portInput = document.getElementById('port') as HTMLInputElement;
4
4
  const debugRichTextInput = document.getElementById('debugRichText') as HTMLInputElement;
5
5
  const saveBtn = document.getElementById('save') as HTMLButtonElement;
6
+ const reconnectBtn = document.getElementById('reconnect') as HTMLButtonElement;
6
7
  const disconnectBtn = document.getElementById('disconnect') as HTMLButtonElement;
8
+ const connectionStateEl = document.getElementById('connectionState') as HTMLDivElement;
9
+ const tokenStateEl = document.getElementById('tokenState') as HTMLDivElement;
10
+ const reconnectStateEl = document.getElementById('reconnectState') as HTMLDivElement;
11
+ const connectionUrlEl = document.getElementById('connectionUrl') as HTMLDivElement;
12
+ const lastErrorEl = document.getElementById('lastError') as HTMLDivElement;
13
+ const lastBindingUpdateEl = document.getElementById('lastBindingUpdate') as HTMLDivElement;
14
+ const extensionVersionEl = document.getElementById('extensionVersion') as HTMLDivElement;
15
+ const sessionSummaryEl = document.getElementById('sessionSummary') as HTMLDivElement;
16
+ const sessionListEl = document.getElementById('sessionList') as HTMLUListElement;
17
+
18
+ interface PopupState {
19
+ ok: boolean;
20
+ connected: boolean;
21
+ connectionState: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'manual' | 'missing-token';
22
+ hasToken: boolean;
23
+ port: number;
24
+ wsUrl: string;
25
+ debugRichText: boolean;
26
+ lastError: string | null;
27
+ lastErrorAt: number | null;
28
+ lastErrorContext: string | null;
29
+ reconnectAttempt: number;
30
+ nextReconnectInMs: number | null;
31
+ manualDisconnect: boolean;
32
+ extensionVersion: string;
33
+ lastBindingUpdateAt: number | null;
34
+ lastBindingUpdateReason: string | null;
35
+ sessionBindings: {
36
+ count: number;
37
+ attachedCount: number;
38
+ detachedCount: number;
39
+ tabCount: number;
40
+ items: Array<{
41
+ id: string;
42
+ label: string;
43
+ tabCount: number;
44
+ activeTabId: number | null;
45
+ windowId: number | null;
46
+ groupId: number | null;
47
+ detached: boolean;
48
+ }>;
49
+ };
50
+ }
51
+ let latestState: PopupState | null = null;
7
52
 
8
53
  function setStatus(text: string, bad = false): void {
9
54
  statusEl.textContent = text;
10
55
  statusEl.style.color = bad ? '#dc2626' : '#0f172a';
11
56
  }
12
57
 
58
+ function formatTimeAgo(at: number | null): string {
59
+ if (typeof at !== 'number') {
60
+ return 'never';
61
+ }
62
+ const deltaSeconds = Math.max(0, Math.round((Date.now() - at) / 1000));
63
+ if (deltaSeconds < 5) {
64
+ return 'just now';
65
+ }
66
+ if (deltaSeconds < 60) {
67
+ return `${deltaSeconds}s ago`;
68
+ }
69
+ const deltaMinutes = Math.round(deltaSeconds / 60);
70
+ if (deltaMinutes < 60) {
71
+ return `${deltaMinutes}m ago`;
72
+ }
73
+ const deltaHours = Math.round(deltaMinutes / 60);
74
+ return `${deltaHours}h ago`;
75
+ }
76
+
77
+ function renderSessionBindings(state: PopupState['sessionBindings']): void {
78
+ sessionSummaryEl.textContent = `${state.count} sessions, ${state.attachedCount} attached, ${state.tabCount} tabs, ${state.detachedCount} detached`;
79
+ sessionListEl.replaceChildren();
80
+ for (const item of state.items) {
81
+ const li = document.createElement('li');
82
+ const location = item.windowId === null ? 'no window' : `window ${item.windowId}`;
83
+ const active = item.activeTabId === null ? 'no active tab' : `active ${item.activeTabId}`;
84
+ li.textContent = `${item.label}: ${item.tabCount} tabs, ${location}, ${active}`;
85
+ if (item.detached) {
86
+ li.style.color = '#b45309';
87
+ }
88
+ sessionListEl.appendChild(li);
89
+ }
90
+ }
91
+
92
+ function renderConnectionDetails(state: PopupState): void {
93
+ connectionStateEl.textContent = state.connectionState;
94
+ tokenStateEl.textContent = state.hasToken ? 'configured' : 'missing';
95
+ connectionUrlEl.textContent = state.wsUrl;
96
+ extensionVersionEl.textContent = state.extensionVersion;
97
+
98
+ if (state.manualDisconnect) {
99
+ reconnectStateEl.textContent = 'manual disconnect';
100
+ } else if (typeof state.nextReconnectInMs === 'number') {
101
+ const seconds = Math.max(0, Math.ceil(state.nextReconnectInMs / 100) / 10);
102
+ reconnectStateEl.textContent = `attempt ${state.reconnectAttempt}, retry in ${seconds}s`;
103
+ } else if (state.connected) {
104
+ reconnectStateEl.textContent = 'connected';
105
+ } else {
106
+ reconnectStateEl.textContent = 'idle';
107
+ }
108
+
109
+ if (state.lastError) {
110
+ const context = state.lastErrorContext ? `${state.lastErrorContext}: ` : '';
111
+ lastErrorEl.textContent = `${context}${state.lastError} (${formatTimeAgo(state.lastErrorAt)})`;
112
+ } else {
113
+ lastErrorEl.textContent = 'none';
114
+ }
115
+
116
+ if (state.lastBindingUpdateReason) {
117
+ lastBindingUpdateEl.textContent = `${state.lastBindingUpdateReason} (${formatTimeAgo(state.lastBindingUpdateAt)})`;
118
+ } else {
119
+ lastBindingUpdateEl.textContent = 'none';
120
+ }
121
+ }
122
+
13
123
  async function refreshState(): Promise<void> {
14
- const state = (await chrome.runtime.sendMessage({ type: 'bak.getState' })) as {
15
- ok: boolean;
16
- connected: boolean;
17
- hasToken: boolean;
18
- port: number;
19
- debugRichText: boolean;
20
- lastError: string | null;
21
- };
124
+ const state = (await chrome.runtime.sendMessage({ type: 'bak.getState' })) as PopupState;
22
125
 
23
126
  if (state.ok) {
127
+ latestState = state;
24
128
  portInput.value = String(state.port);
25
129
  debugRichTextInput.checked = Boolean(state.debugRichText);
130
+ renderConnectionDetails(state);
131
+ renderSessionBindings(state.sessionBindings);
26
132
  if (state.connected) {
27
133
  setStatus('Connected to bak CLI');
134
+ } else if (state.connectionState === 'missing-token') {
135
+ setStatus('Pair token is required', true);
136
+ } else if (state.connectionState === 'manual') {
137
+ setStatus('Disconnected manually');
138
+ } else if (state.connectionState === 'reconnecting') {
139
+ setStatus('Reconnecting to bak CLI', true);
28
140
  } else if (state.lastError) {
29
141
  setStatus(`Disconnected: ${state.lastError}`, true);
30
142
  } else {
@@ -37,7 +149,7 @@ saveBtn.addEventListener('click', async () => {
37
149
  const token = tokenInput.value.trim();
38
150
  const port = Number.parseInt(portInput.value.trim(), 10);
39
151
 
40
- if (!token) {
152
+ if (!token && latestState?.hasToken !== true) {
41
153
  setStatus('Pair token is required', true);
42
154
  return;
43
155
  }
@@ -49,11 +161,17 @@ saveBtn.addEventListener('click', async () => {
49
161
 
50
162
  await chrome.runtime.sendMessage({
51
163
  type: 'bak.updateConfig',
52
- token,
164
+ ...(token ? { token } : {}),
53
165
  port,
54
166
  debugRichText: debugRichTextInput.checked
55
167
  });
56
168
 
169
+ tokenInput.value = '';
170
+ await refreshState();
171
+ });
172
+
173
+ reconnectBtn.addEventListener('click', async () => {
174
+ await chrome.runtime.sendMessage({ type: 'bak.reconnectNow' });
57
175
  await refreshState();
58
176
  });
59
177
 
@@ -62,4 +180,10 @@ disconnectBtn.addEventListener('click', async () => {
62
180
  await refreshState();
63
181
  });
64
182
 
65
- void refreshState();
183
+ void refreshState();
184
+ const refreshInterval = window.setInterval(() => {
185
+ void refreshState();
186
+ }, 1000);
187
+ window.addEventListener('unload', () => {
188
+ window.clearInterval(refreshInterval);
189
+ });
@@ -451,22 +451,9 @@ class SessionBindingManager {
451
451
  const remainingTabIds = ensured.binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
452
452
 
453
453
  if (remainingTabIds.length === 0) {
454
- const emptied: SessionBindingRecord = {
455
- id: ensured.binding.id,
456
- label: ensured.binding.label,
457
- color: ensured.binding.color,
458
- windowId: null,
459
- groupId: null,
460
- tabIds: [],
461
- activeTabId: null,
462
- primaryTabId: null
463
- };
464
- await this.storage.save(emptied);
454
+ await this.storage.delete(ensured.binding.id);
465
455
  return {
466
- binding: {
467
- ...emptied,
468
- tabs: []
469
- },
456
+ binding: null,
470
457
  closedTabId: resolvedTabId
471
458
  };
472
459
  }