@co0ontty/wand 1.17.5 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,7 @@ export declare class ProcessManager extends EventEmitter {
44
44
  worktreeEnabled?: boolean;
45
45
  provider?: SessionProvider;
46
46
  model?: string;
47
+ reuseId?: string;
47
48
  }): SessionSnapshot;
48
49
  list(): SessionSnapshot[];
49
50
  /** Return lightweight snapshots for the session list (no output/messages). */
@@ -69,6 +70,7 @@ export declare class ProcessManager extends EventEmitter {
69
70
  private emitTask;
70
71
  resize(id: string, cols: number, rows: number): SessionSnapshot;
71
72
  stop(id: string): SessionSnapshot;
73
+ private cleanupRecord;
72
74
  delete(id: string): void;
73
75
  private deleteClaudeCache;
74
76
  runStartupCommands(): SessionSnapshot[];
@@ -104,8 +106,8 @@ export declare class ProcessManager extends EventEmitter {
104
106
  /**
105
107
  * Auto-recover the most recent exited session that has a Claude session ID.
106
108
  * Only resumes one session per server start, using the most recent eligible
107
- * session. Sets `resumedToSessionId` on the original session and
108
- * `autoRecovered: true` on the new session.
109
+ * session. Reuses the original session ID (in-place resume) and sets
110
+ * `autoRecovered: true`.
109
111
  */
110
112
  private autoRecoverExitedSessions;
111
113
  private archiveExpiredSessions;
@@ -608,13 +608,23 @@ export class ProcessManager extends EventEmitter {
608
608
  this.lastPersistedMessageState.delete(id);
609
609
  this.storage.deleteSession(id);
610
610
  }
611
+ if (toRemove.length > 0) {
612
+ this.claudeHistoryCache = null;
613
+ }
611
614
  }
612
615
  start(command, cwd, mode, initialInput, opts) {
613
616
  this.assertCommandAllowed(command);
614
617
  const baseCwd = cwd
615
618
  ? path.resolve(process.cwd(), cwd)
616
619
  : path.resolve(process.cwd(), this.config.defaultCwd);
617
- const id = randomUUID();
620
+ const id = opts?.reuseId || randomUUID();
621
+ if (opts?.reuseId) {
622
+ const oldRecord = this.sessions.get(id);
623
+ if (oldRecord) {
624
+ this.cleanupRecord(oldRecord);
625
+ this.sessions.delete(id);
626
+ }
627
+ }
618
628
  const worktreeSetup = opts?.worktreeEnabled
619
629
  ? prepareSessionWorktree({ cwd: baseCwd, sessionId: id })
620
630
  : null;
@@ -690,6 +700,9 @@ export class ProcessManager extends EventEmitter {
690
700
  }
691
701
  this.sessions.set(id, record);
692
702
  this.persist(record);
703
+ if (initialClaudeSessionId) {
704
+ this.claudeHistoryCache = null;
705
+ }
693
706
  this.cleanupOldSessions();
694
707
  this.lifecycleManager.register(id, "initializing");
695
708
  const shellArgs = this.buildShellArgs(processedCommand);
@@ -800,6 +813,7 @@ export class ProcessManager extends EventEmitter {
800
813
  const bridgeSessionId = rec.ptyBridge?.getClaudeSessionId();
801
814
  if (bridgeSessionId && bridgeSessionId !== rec.claudeSessionId) {
802
815
  rec.claudeSessionId = bridgeSessionId;
816
+ this.claudeHistoryCache = null;
803
817
  process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
804
818
  }
805
819
  if (!rec.claudeSessionId && rec.knownClaudeTaskIds) {
@@ -1083,6 +1097,41 @@ export class ProcessManager extends EventEmitter {
1083
1097
  this.persist(record);
1084
1098
  return this.snapshot(record);
1085
1099
  }
1100
+ cleanupRecord(record) {
1101
+ if (record.taskDebounceTimer) {
1102
+ clearTimeout(record.taskDebounceTimer);
1103
+ record.taskDebounceTimer = null;
1104
+ }
1105
+ if (record.claudeTaskDiscoveryTimer) {
1106
+ clearTimeout(record.claudeTaskDiscoveryTimer);
1107
+ record.claudeTaskDiscoveryTimer = null;
1108
+ }
1109
+ if (record.initialInputTimer) {
1110
+ clearTimeout(record.initialInputTimer);
1111
+ record.initialInputTimer = null;
1112
+ }
1113
+ const pendingPersist = this.persistDebounceTimers.get(record.id);
1114
+ if (pendingPersist) {
1115
+ clearTimeout(pendingPersist);
1116
+ this.persistDebounceTimers.delete(record.id);
1117
+ }
1118
+ if (record.status === "running") {
1119
+ record.stopRequested = true;
1120
+ if (record.childProcess) {
1121
+ record.childProcess.kill();
1122
+ record.childProcess = null;
1123
+ }
1124
+ if (record.ptyProcess) {
1125
+ record.ptyProcess.kill();
1126
+ record.ptyProcess = null;
1127
+ }
1128
+ }
1129
+ if (record.ptyBridge) {
1130
+ record.ptyBridge.removeAllListeners();
1131
+ record.ptyBridge = null;
1132
+ }
1133
+ this.lifecycleManager.unregister(record.id);
1134
+ }
1086
1135
  delete(id) {
1087
1136
  const record = this.mustGet(id);
1088
1137
  // Always clear pending timers
@@ -1135,6 +1184,9 @@ export class ProcessManager extends EventEmitter {
1135
1184
  this.sessions.delete(id);
1136
1185
  this.lastPersistedMessageState.delete(id);
1137
1186
  this.lifecycleManager.unregister(id);
1187
+ if (record.claudeSessionId) {
1188
+ this.claudeHistoryCache = null;
1189
+ }
1138
1190
  }
1139
1191
  deleteClaudeCache(record) {
1140
1192
  if (!record.claudeSessionId)
@@ -1357,8 +1409,8 @@ export class ProcessManager extends EventEmitter {
1357
1409
  /**
1358
1410
  * Auto-recover the most recent exited session that has a Claude session ID.
1359
1411
  * Only resumes one session per server start, using the most recent eligible
1360
- * session. Sets `resumedToSessionId` on the original session and
1361
- * `autoRecovered: true` on the new session.
1412
+ * session. Reuses the original session ID (in-place resume) and sets
1413
+ * `autoRecovered: true`.
1362
1414
  */
1363
1415
  autoRecoverExitedSessions() {
1364
1416
  // Find eligible exited sessions
@@ -1393,19 +1445,12 @@ export class ProcessManager extends EventEmitter {
1393
1445
  }
1394
1446
  console.error(`[ProcessManager] Auto-recovering session ${original.id} with Claude session ID ${original.claudeSessionId}`);
1395
1447
  const resumeCommand = `${original.command.trim()} --resume ${original.claudeSessionId}`;
1396
- let newRecord = null;
1397
1448
  try {
1398
1449
  const snapshot = this.start(resumeCommand, original.cwd, original.mode, undefined, {
1399
- resumedFromSessionId: original.id,
1450
+ reuseId: original.id,
1400
1451
  autoRecovered: true
1401
1452
  });
1402
- newRecord = this.sessions.get(snapshot.id) ?? null;
1403
- if (!newRecord)
1404
- return;
1405
- // Set resumedToSessionId on the original session
1406
- original.resumedToSessionId = snapshot.id;
1407
- this.storage.saveSession(this.snapshot(original));
1408
- console.error(`[ProcessManager] Auto-recovered session ${snapshot.id} from ${original.id}`);
1453
+ console.error(`[ProcessManager] Auto-recovered session ${snapshot.id} (in-place)`);
1409
1454
  }
1410
1455
  catch (err) {
1411
1456
  console.error(`[ProcessManager] Auto-recovery failed: ${String(err)}`);
@@ -406,8 +406,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
406
406
  }
407
407
  const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
408
408
  const resumeCommand = `${command} --resume ${claudeSessionId}`;
409
- const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { resumedFromSessionId: sessionId });
410
- storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id, archived: true });
409
+ const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: sessionId });
411
410
  res.status(201).json({ resumedFromSessionId: sessionId, ...newSnapshot });
412
411
  }
413
412
  catch (error) {
@@ -444,8 +443,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
444
443
  }
445
444
  const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
446
445
  const resumeCommand = `${command} --resume ${claudeSessionId}`;
447
- const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { resumedFromSessionId: existingSession.id });
448
- storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id, archived: true });
446
+ const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: existingSession.id });
449
447
  res.status(201).json({ resumedFromSessionId: existingSession.id, resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
450
448
  }
451
449
  else {
@@ -842,6 +842,9 @@
842
842
  if (!state.ws || (state.ws.readyState !== WebSocket.OPEN && state.ws.readyState !== WebSocket.CONNECTING)) {
843
843
  initWebSocket();
844
844
  }
845
+ if (state.claudeHistoryLoaded) {
846
+ loadClaudeHistory();
847
+ }
845
848
  return loadSessions({ skipSelectedOutputReload: true }).then(function() {
846
849
  if (state.selectedId) {
847
850
  return loadOutput(state.selectedId);
@@ -1201,13 +1204,13 @@
1201
1204
  '<span class="terminal-scale-overlay-divider"></span>' +
1202
1205
  '<button id="page-refresh-btn" class="terminal-scale-overlay-btn" type="button" title="刷新页面">↻</button>' +
1203
1206
  '</div>' +
1204
- '<button id="terminal-jump-bottom" class="terminal-jump-bottom' + (state.showTerminalJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部">↓ 最新</button>' +
1207
+ '<button id="terminal-jump-bottom" class="terminal-jump-bottom' + (state.showTerminalJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部" aria-label="回到底部"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8l4.5 4.5L12.5 8"/></svg></button>' +
1205
1208
  '</div>' +
1206
1209
  '<div id="chat-output" class="chat-container hidden">' +
1207
1210
  '<div class="chat-overlay-controls">' +
1208
1211
  '<button id="chat-follow-toggle" class="chat-follow-toggle topbar-btn' + (state.chatAutoFollow ? ' active' : '') + '" type="button" aria-pressed="' + (state.chatAutoFollow ? 'true' : 'false') + '" title="' + (state.chatAutoFollow ? '追踪底部:开启' : '追踪底部:已暂停') + '">' + (state.chatAutoFollow ? '追底' : '暂停') + '</button>' +
1209
1212
  '</div>' +
1210
- '<button id="chat-jump-bottom" class="chat-jump-bottom' + (state.showChatJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部并继续追底">↓ 最新</button>' +
1213
+ '<button id="chat-jump-bottom" class="chat-jump-bottom' + (state.showChatJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部并继续追底" aria-label="回到底部"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8l4.5 4.5L12.5 8"/></svg></button>' +
1211
1214
  '</div>' +
1212
1215
  '<div id="blank-chat" class="blank-chat' + (state.selectedId ? " hidden" : "") + '">' +
1213
1216
  '<div class="blank-chat-inner">' +
@@ -1499,6 +1502,14 @@
1499
1502
  '</div>' +
1500
1503
  '<p class="hint" style="margin-top:0">\u9009\u62e9 Android \u7cfb\u7edf\u901a\u77e5\u4f7f\u7528\u7684\u94c3\u58f0</p>' +
1501
1504
  '</div>' +
1505
+ '<div id="native-haptic-section" class="settings-notification-section hidden" style="margin-top:6px">' +
1506
+ '<div class="settings-section-title">\u89e6\u611f\u53cd\u9988</div>' +
1507
+ '<div class="field field-inline" style="margin:4px 0">' +
1508
+ '<input id="cfg-haptic-enabled" type="checkbox" class="field-checkbox" />' +
1509
+ '<label class="field-label" for="cfg-haptic-enabled">\u542f\u7528\u89e6\u611f\u53cd\u9988</label>' +
1510
+ '</div>' +
1511
+ '<p class="hint" style="margin-top:0">\u6309\u94ae\u64cd\u4f5c\u548c\u4efb\u52a1\u5b8c\u6210\u65f6\u63d0\u4f9b\u632f\u52a8\u53cd\u9988</p>' +
1512
+ '</div>' +
1502
1513
  '<div class="settings-notification-section" style="margin-top:6px">' +
1503
1514
  '<div class="settings-section-title">\u6d4f\u89c8\u5668\u901a\u77e5</div>' +
1504
1515
  '<div class="settings-about-row">' +
@@ -1857,8 +1868,12 @@
1857
1868
  }
1858
1869
 
1859
1870
  function getVisibleClaudeHistorySessions() {
1871
+ var managedIds = new Set();
1872
+ state.sessions.forEach(function(s) {
1873
+ if (s.claudeSessionId) managedIds.add(s.claudeSessionId);
1874
+ });
1860
1875
  return state.claudeHistory.filter(function(s) {
1861
- return s.hasConversation && !s.managedByWand;
1876
+ return s.hasConversation && !s.managedByWand && !managedIds.has(s.claudeSessionId);
1862
1877
  });
1863
1878
  }
1864
1879
 
@@ -3483,6 +3498,19 @@
3483
3498
  } catch (_e) {}
3484
3499
  }
3485
3500
  }
3501
+ // Native haptic toggle (APK only)
3502
+ if (_hasNativeBridge && typeof WandNative.isHapticEnabled === "function") {
3503
+ var hapticSection = document.getElementById("native-haptic-section");
3504
+ var hapticToggle = document.getElementById("cfg-haptic-enabled");
3505
+ if (hapticSection && hapticToggle) {
3506
+ hapticSection.classList.remove("hidden");
3507
+ try { hapticToggle.checked = WandNative.isHapticEnabled(); } catch (_e) {}
3508
+ hapticToggle.addEventListener("change", function() {
3509
+ try { WandNative.setHapticEnabled(hapticToggle.checked); } catch (_e) {}
3510
+ if (hapticToggle.checked) _vibrate("medium");
3511
+ });
3512
+ }
3513
+ }
3486
3514
  var newSessBtn = document.getElementById("topbar-new-session-button");
3487
3515
  if (newSessBtn) newSessBtn.addEventListener("click", openSessionModal);
3488
3516
  var drawerNewSessBtn = document.getElementById("drawer-new-session-button");
@@ -4245,16 +4273,14 @@
4245
4273
  if (!badge) return;
4246
4274
  var fullId = badge.dataset.claudeId;
4247
4275
  if (!fullId) return;
4248
- navigator.clipboard.writeText(fullId).then(function() {
4249
- var original = badge.textContent;
4276
+ var original = badge.textContent;
4277
+ copyToClipboard(fullId, null, function() {
4250
4278
  badge.textContent = "\u2713 已复制";
4251
4279
  badge.classList.add("copied");
4252
4280
  setTimeout(function() {
4253
4281
  badge.textContent = original;
4254
4282
  badge.classList.remove("copied");
4255
4283
  }, 1200);
4256
- }).catch(function() {
4257
- showToast("复制失败", "error");
4258
4284
  });
4259
4285
  }
4260
4286
 
@@ -4838,6 +4864,9 @@
4838
4864
  : "向 Codex 发送输入;chat 为解析后的阅读视图";
4839
4865
  }
4840
4866
  if (session && !isStructuredSession(session) && session.status !== "running") {
4867
+ if (canAutoResumeSession(session)) {
4868
+ return "输入消息...";
4869
+ }
4841
4870
  return "会话已结束,无法继续发送";
4842
4871
  }
4843
4872
  return session && isStructuredSession(session) && session.structuredState && session.structuredState.inFlight
@@ -5389,6 +5418,7 @@
5389
5418
  flushCrossSessionQueue();
5390
5419
  }
5391
5420
  renderCrossSessionQueue();
5421
+ _syncWakeLock();
5392
5422
  });
5393
5423
  })
5394
5424
  .catch(function(e) {
@@ -5948,6 +5978,12 @@
5948
5978
  try { localStorage.setItem("wand-notif-volume", String(nativeVol)); } catch (_e) {}
5949
5979
  } catch (_e) {}
5950
5980
  }
5981
+ if (_hasNativeBridge && typeof WandNative.isHapticEnabled === "function") {
5982
+ try {
5983
+ var hapticEl = document.getElementById("cfg-haptic-enabled");
5984
+ if (hapticEl) hapticEl.checked = WandNative.isHapticEnabled();
5985
+ } catch (_e) {}
5986
+ }
5951
5987
  }
5952
5988
  }
5953
5989
 
@@ -6094,16 +6130,23 @@
6094
6130
  }
6095
6131
 
6096
6132
 
6097
- function copyToClipboard(text, triggerBtn) {
6133
+ function copyToClipboard(text, triggerBtn, successCallback) {
6098
6134
  if (!text) return;
6099
- navigator.clipboard.writeText(text).then(function() {
6135
+ function onSuccess() {
6136
+ _vibrate("light");
6137
+ if (successCallback) { successCallback(); return; }
6100
6138
  if (triggerBtn) {
6101
6139
  var orig = triggerBtn.textContent;
6102
6140
  triggerBtn.textContent = "已复制";
6103
6141
  setTimeout(function() { triggerBtn.textContent = orig; }, 1500);
6104
6142
  }
6105
- }).catch(function() {
6106
- // Fallback for non-secure contexts
6143
+ }
6144
+ if (_hasNativeBridge && typeof WandNative.copyToClipboard === "function") {
6145
+ try {
6146
+ if (WandNative.copyToClipboard(text) === "ok") { onSuccess(); return; }
6147
+ } catch (_e) {}
6148
+ }
6149
+ navigator.clipboard.writeText(text).then(onSuccess).catch(function() {
6107
6150
  var ta = document.createElement("textarea");
6108
6151
  ta.value = text;
6109
6152
  ta.style.position = "fixed";
@@ -6112,11 +6155,7 @@
6112
6155
  ta.select();
6113
6156
  document.execCommand("copy");
6114
6157
  document.body.removeChild(ta);
6115
- if (triggerBtn) {
6116
- var orig = triggerBtn.textContent;
6117
- triggerBtn.textContent = "已复制";
6118
- setTimeout(function() { triggerBtn.textContent = orig; }, 1500);
6119
- }
6158
+ onSuccess();
6120
6159
  });
6121
6160
  }
6122
6161
 
@@ -8214,7 +8253,6 @@
8214
8253
  if (!data) return null;
8215
8254
  updateSessionSnapshot(data);
8216
8255
  updateSessionsList();
8217
- switchToSessionView(data.id);
8218
8256
  subscribeToSession(data.id);
8219
8257
  return loadOutput(data.id).then(function() {
8220
8258
  focusInputBox(true);
@@ -8985,6 +9023,9 @@
8985
9023
  else showToast(data.error, "error");
8986
9024
  return null;
8987
9025
  }
9026
+ state.claudeHistory = state.claudeHistory.filter(function(s) {
9027
+ return s.claudeSessionId !== claudeSessionId;
9028
+ });
8988
9029
  state.selectedId = data.id;
8989
9030
  persistSelectedId();
8990
9031
  state.drafts[data.id] = "";
@@ -9014,6 +9055,11 @@
9014
9055
  console.log("[WAND] resumeSessionFromList sessionId:", sessionId);
9015
9056
  return resumeSession(sessionId).then(function(data) {
9016
9057
  if (!data) return null;
9058
+ if (data.claudeSessionId) {
9059
+ state.claudeHistory = state.claudeHistory.filter(function(s) {
9060
+ return s.claudeSessionId !== data.claudeSessionId;
9061
+ });
9062
+ }
9017
9063
  return activateSession(data).then(function() {
9018
9064
  return data;
9019
9065
  });
@@ -9121,11 +9167,13 @@
9121
9167
  resumeClaudeHistorySession(claudeSessionId, cwd)
9122
9168
  .then(function(data) {
9123
9169
  if (data && data.id) {
9170
+ state.claudeHistory = state.claudeHistory.filter(function(s) {
9171
+ return s.claudeSessionId !== claudeSessionId;
9172
+ });
9124
9173
  state.selectedId = data.id;
9125
9174
  persistSelectedId();
9126
9175
  state.drafts[data.id] = "";
9127
- loadSessions().then(function() {
9128
- selectSession(data.id);
9176
+ activateSession(data).then(function() {
9129
9177
  closeSessionsDrawer();
9130
9178
  });
9131
9179
  }
@@ -10489,8 +10537,10 @@
10489
10537
  } else {
10490
10538
  endedNotifBody = endedSession ? (endedSession.command || msg.sessionId) : msg.sessionId;
10491
10539
  }
10540
+ _vibrate(endedIsError ? "error" : "success");
10492
10541
  notifyTaskEnded(msg.sessionId, endedNotifTitle, endedNotifBody);
10493
10542
  clearSessionProgressNative(msg.sessionId);
10543
+ _syncWakeLock();
10494
10544
  if (msg.sessionId !== state.selectedId || document.hidden) {
10495
10545
  showNotificationBubble({
10496
10546
  title: endedNotifTitle,
@@ -10629,6 +10679,7 @@
10629
10679
  } else {
10630
10680
  permBody += "\n" + permDetail;
10631
10681
  }
10682
+ _vibrate("medium");
10632
10683
  notifyPermissionRequest(msg.sessionId, permBody);
10633
10684
  // In-app bubble if not currently viewing this session
10634
10685
  if (msg.sessionId !== state.selectedId) {
@@ -10653,6 +10704,7 @@
10653
10704
  }
10654
10705
  updateSessionSnapshot(statusUpdate);
10655
10706
  syncSessionProgressToNative(msg.sessionId);
10707
+ _syncWakeLock();
10656
10708
  if (msg.sessionId === state.selectedId) {
10657
10709
  updateTaskDisplay();
10658
10710
  if (msg.data.approvalStats) {
@@ -10777,6 +10829,7 @@
10777
10829
  }
10778
10830
 
10779
10831
  function approvePermission() {
10832
+ _vibrate("light");
10780
10833
  if (!state.selectedId) return;
10781
10834
  var approveBtn = document.getElementById("approve-permission-btn");
10782
10835
  var denyBtn = document.getElementById("deny-permission-btn");
@@ -10805,6 +10858,7 @@
10805
10858
  }
10806
10859
 
10807
10860
  function denyPermission() {
10861
+ _vibrate("light");
10808
10862
  if (!state.selectedId) return;
10809
10863
  var approveBtn = document.getElementById("approve-permission-btn");
10810
10864
  var denyBtn = document.getElementById("deny-permission-btn");
@@ -11507,13 +11561,10 @@
11507
11561
  var codeBlock = btn.closest(".code-block");
11508
11562
  var code = codeBlock ? codeBlock.querySelector("code") : null;
11509
11563
  if (code) {
11510
- navigator.clipboard.writeText(code.textContent || "").then(function() {
11564
+ copyToClipboard(code.textContent || "", null, function() {
11511
11565
  btn.textContent = "Copied!";
11512
11566
  btn.classList.add("copied");
11513
- setTimeout(function() {
11514
- btn.textContent = "Copy";
11515
- btn.classList.remove("copied");
11516
- }, 2000);
11567
+ setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 2000);
11517
11568
  });
11518
11569
  }
11519
11570
  });
@@ -11522,25 +11573,20 @@
11522
11573
 
11523
11574
  function attachAllCopyHandlers(container) {
11524
11575
  container.querySelectorAll(".code-copy").forEach(function(btn) {
11525
- // Remove existing listeners by cloning
11526
11576
  var clone = btn.cloneNode(true);
11527
11577
  btn.parentNode.replaceChild(clone, btn);
11528
11578
  clone.addEventListener("click", function() {
11529
11579
  var codeBlock = clone.closest(".code-block");
11530
11580
  var code = codeBlock ? codeBlock.querySelector("code") : null;
11531
11581
  if (code) {
11532
- navigator.clipboard.writeText(code.textContent || "").then(function() {
11582
+ copyToClipboard(code.textContent || "", null, function() {
11533
11583
  clone.textContent = "Copied!";
11534
11584
  clone.classList.add("copied");
11535
- setTimeout(function() {
11536
- clone.textContent = "Copy";
11537
- clone.classList.remove("copied");
11538
- }, 2000);
11585
+ setTimeout(function() { clone.textContent = "Copy"; clone.classList.remove("copied"); }, 2000);
11539
11586
  });
11540
11587
  }
11541
11588
  });
11542
11589
  });
11543
- // Attach message-level copy buttons for touch devices
11544
11590
  attachMessageCopyButtons(container);
11545
11591
  }
11546
11592
 
@@ -11560,7 +11606,7 @@
11560
11606
  btn.addEventListener("click", function(e) {
11561
11607
  e.stopPropagation();
11562
11608
  var text = bubble.innerText || bubble.textContent || "";
11563
- navigator.clipboard.writeText(text.trim()).then(function() {
11609
+ copyToClipboard(text.trim(), null, function() {
11564
11610
  btn.textContent = "已复制";
11565
11611
  btn.classList.add("copied");
11566
11612
  setTimeout(function() {
@@ -13629,6 +13675,30 @@
13629
13675
  var _apkVersionMatch = navigator.userAgent.match(/WandApp\/([^\s]+)/);
13630
13676
  var _apkVersion = _apkVersionMatch ? _apkVersionMatch[1] : null;
13631
13677
 
13678
+ function _vibrate(pattern) {
13679
+ if (!_hasNativeBridge || typeof WandNative.vibrate !== "function") return;
13680
+ try { WandNative.vibrate(pattern || "light"); } catch (_e) {}
13681
+ }
13682
+
13683
+ function _syncWakeLock() {
13684
+ if (!_hasNativeBridge) return;
13685
+ var anyActive = state.sessions.some(function(s) {
13686
+ return !s.archived && (s.status === "running" || s.status === "thinking" || s.status === "initializing");
13687
+ });
13688
+ if (typeof WandNative.setKeepScreenOn === "function") {
13689
+ try { WandNative.setKeepScreenOn(anyActive); } catch (_e) {}
13690
+ }
13691
+ if (anyActive) {
13692
+ if (typeof WandNative.startKeepAlive === "function") {
13693
+ try { WandNative.startKeepAlive(); } catch (_e) {}
13694
+ }
13695
+ } else {
13696
+ if (typeof WandNative.stopKeepAlive === "function") {
13697
+ try { WandNative.stopKeepAlive(); } catch (_e) {}
13698
+ }
13699
+ }
13700
+ }
13701
+
13632
13702
  function _getNativePermission() {
13633
13703
  if (_hasNativeBridge && typeof WandNative.getPermission === "function") {
13634
13704
  try { return WandNative.getPermission(); } catch (_e) {}
@@ -13848,6 +13918,41 @@
13848
13918
  try { WandNative.clearSessionProgress(sessionId); } catch (_e) {}
13849
13919
  }
13850
13920
 
13921
+ // ── Android back button handler ──
13922
+ window.handleNativeBack = function() {
13923
+ var settingsModal = document.getElementById("settings-modal");
13924
+ if (settingsModal && !settingsModal.classList.contains("hidden")) {
13925
+ closeSettingsModal();
13926
+ return true;
13927
+ }
13928
+ var sessionModal = document.getElementById("session-modal");
13929
+ if (sessionModal && !sessionModal.classList.contains("hidden")) {
13930
+ closeSessionModal();
13931
+ return true;
13932
+ }
13933
+ var worktreeModal = document.getElementById("worktree-merge-modal");
13934
+ if (worktreeModal && !worktreeModal.classList.contains("hidden")) {
13935
+ closeWorktreeMergeModal();
13936
+ return true;
13937
+ }
13938
+ if (state.filePanelOpen && isMobileLayout()) {
13939
+ setFilePanelOpen(false);
13940
+ return true;
13941
+ }
13942
+ if (state.sessionsDrawerOpen && isMobileLayout()) {
13943
+ closeSessionsDrawer();
13944
+ return true;
13945
+ }
13946
+ if (isMobileLayout() && state.selectedId) {
13947
+ state.selectedId = null;
13948
+ persistSelectedId();
13949
+ state.sessionsDrawerOpen = true;
13950
+ render();
13951
+ return true;
13952
+ }
13953
+ return false;
13954
+ };
13955
+
13851
13956
  /**
13852
13957
  * Play a soft, rounded notification chime using Web Audio API.
13853
13958
  * Two ascending sine tones with smooth gain envelope — gentle on the ears.
@@ -122,16 +122,19 @@
122
122
  }
123
123
 
124
124
  /* ===== PWA 独立窗口模式 ===== */
125
+ /* 顶部安全区:用 max() 保底,避免 iPad 横屏/Stage Manager 等 inset 为 0 的场景下贴顶,
126
+ 同时为 iPadOS Stage Manager 浮动控件预留视觉缓冲 */
125
127
  @media (display-mode: standalone) {
126
128
  :root {
127
129
  --safe-bottom: env(safe-area-inset-bottom, 0px);
130
+ --pwa-safe-top: max(env(safe-area-inset-top, 0px), 14px);
128
131
  }
129
132
  .app-container {
130
- --pwa-top-inset: env(safe-area-inset-top, 0px);
131
- padding-top: env(safe-area-inset-top, 0px);
133
+ --pwa-top-inset: var(--pwa-safe-top);
134
+ padding-top: var(--pwa-safe-top);
132
135
  }
133
136
  .sidebar-header {
134
- padding-top: calc(14px + env(safe-area-inset-top, 0px));
137
+ padding-top: calc(14px + var(--pwa-safe-top));
135
138
  }
136
139
  .main-content {
137
140
  padding-top: 0;
@@ -141,13 +144,14 @@
141
144
  /* iOS Safari PWA fallback (navigator.standalone adds .is-pwa via JS) */
142
145
  .is-pwa {
143
146
  --safe-bottom: env(safe-area-inset-bottom, 0px);
147
+ --pwa-safe-top: max(env(safe-area-inset-top, 0px), 14px);
144
148
  }
145
149
  .is-pwa .app-container {
146
- --pwa-top-inset: env(safe-area-inset-top, 0px);
147
- padding-top: env(safe-area-inset-top, 0px);
150
+ --pwa-top-inset: var(--pwa-safe-top);
151
+ padding-top: var(--pwa-safe-top);
148
152
  }
149
153
  .is-pwa .sidebar-header {
150
- padding-top: calc(14px + env(safe-area-inset-top, 0px));
154
+ padding-top: calc(14px + var(--pwa-safe-top));
151
155
  }
152
156
 
153
157
  /* ===== PWA 窗口控件覆盖模式 (Window Controls Overlay) ===== */
@@ -2220,12 +2224,22 @@
2220
2224
  display: flex;
2221
2225
  align-items: center;
2222
2226
  justify-content: flex-start;
2223
- padding: 4px 10px;
2227
+ padding: 6px 10px;
2224
2228
  flex-shrink: 0;
2225
- min-height: 0;
2229
+ min-height: 44px;
2226
2230
  gap: 8px;
2227
2231
  }
2228
2232
 
2233
+ /* PWA 模式额外留出顶部缓冲,避免与 iPadOS Stage Manager 等系统浮动控件视觉粘连 */
2234
+ @media (display-mode: standalone) {
2235
+ .main-header-row {
2236
+ padding-top: 10px;
2237
+ }
2238
+ }
2239
+ .is-pwa .main-header-row {
2240
+ padding-top: 10px;
2241
+ }
2242
+
2229
2243
  /* Current task indicator */
2230
2244
  .current-task {
2231
2245
  display: flex;
@@ -2603,38 +2617,66 @@
2603
2617
  .terminal-jump-bottom {
2604
2618
  position: absolute;
2605
2619
  right: 14px;
2606
- bottom: 18px;
2620
+ bottom: 16px;
2607
2621
  display: inline-flex;
2608
2622
  align-items: center;
2609
2623
  justify-content: center;
2610
- gap: 6px;
2611
- min-width: 78px;
2612
- height: 32px;
2613
- padding: 0 12px;
2614
- border: 1px solid rgba(255, 255, 255, 0.15);
2615
- border-radius: 999px;
2616
- background: rgba(24, 20, 17, 0.72);
2624
+ width: 34px;
2625
+ height: 34px;
2626
+ padding: 0;
2627
+ border: 1px solid rgba(255, 255, 255, 0.08);
2628
+ border-radius: 50%;
2629
+ background: rgba(28, 22, 18, 0.78);
2617
2630
  color: rgba(255, 247, 239, 0.92);
2618
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
2619
- backdrop-filter: blur(10px);
2631
+ box-shadow:
2632
+ 0 1px 2px rgba(0, 0, 0, 0.25),
2633
+ 0 8px 22px rgba(0, 0, 0, 0.32),
2634
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
2635
+ backdrop-filter: blur(12px);
2636
+ -webkit-backdrop-filter: blur(12px);
2620
2637
  cursor: pointer;
2621
2638
  z-index: 13;
2622
2639
  opacity: 0;
2623
- transform: translateY(12px) scale(0.92);
2640
+ transform: translateY(10px) scale(0.86);
2624
2641
  pointer-events: none;
2625
- transition: opacity 0.28s var(--ease-out-expo), transform 0.28s var(--ease-out-expo), background 0.18s ease, border-color 0.18s ease;
2642
+ transition:
2643
+ opacity 0.26s var(--ease-out-expo),
2644
+ transform 0.32s var(--ease-spring),
2645
+ background 0.18s ease,
2646
+ border-color 0.18s ease,
2647
+ box-shadow 0.2s ease;
2626
2648
  }
2627
2649
  .terminal-jump-bottom.visible {
2628
2650
  opacity: 1;
2629
2651
  transform: translateY(0) scale(1);
2630
2652
  pointer-events: auto;
2631
2653
  }
2654
+ .terminal-jump-bottom svg {
2655
+ transition: transform 0.2s var(--ease-out-expo);
2656
+ }
2632
2657
  .terminal-jump-bottom:hover {
2633
- background: rgba(38, 30, 24, 0.84);
2634
- border-color: rgba(214, 123, 82, 0.28);
2658
+ background: rgba(214, 123, 82, 0.92);
2659
+ border-color: rgba(255, 255, 255, 0.18);
2660
+ color: #fff;
2661
+ box-shadow:
2662
+ 0 1px 2px rgba(0, 0, 0, 0.28),
2663
+ 0 10px 26px rgba(184, 92, 55, 0.36),
2664
+ inset 0 1px 0 rgba(255, 255, 255, 0.18);
2665
+ }
2666
+ .terminal-jump-bottom:hover svg {
2667
+ transform: translateY(1.5px);
2635
2668
  }
2636
2669
  .terminal-jump-bottom:active {
2637
- transform: translateY(1px) scale(0.97);
2670
+ transform: translateY(0) scale(0.92);
2671
+ transition-duration: 0.08s;
2672
+ }
2673
+ .terminal-jump-bottom:focus-visible {
2674
+ outline: none;
2675
+ box-shadow:
2676
+ 0 1px 2px rgba(0, 0, 0, 0.28),
2677
+ 0 10px 26px rgba(184, 92, 55, 0.36),
2678
+ inset 0 1px 0 rgba(255, 255, 255, 0.18),
2679
+ 0 0 0 3px rgba(214, 123, 82, 0.35);
2638
2680
  }
2639
2681
 
2640
2682
  /* Terminal interactive mode indicator */
@@ -2969,26 +3011,32 @@
2969
3011
  .chat-jump-bottom {
2970
3012
  position: absolute;
2971
3013
  right: 14px;
2972
- bottom: 18px;
3014
+ bottom: 16px;
2973
3015
  display: inline-flex;
2974
3016
  align-items: center;
2975
3017
  justify-content: center;
2976
- gap: 6px;
2977
- min-width: 78px;
2978
- height: 32px;
2979
- padding: 0 12px;
2980
- border: 1px solid rgba(125, 91, 57, 0.16);
2981
- border-radius: 999px;
2982
- background: rgba(255, 250, 242, 0.9);
2983
- color: var(--text-primary);
2984
- box-shadow: 0 4px 12px rgba(89, 58, 32, 0.10);
2985
- backdrop-filter: blur(10px);
3018
+ width: 34px;
3019
+ height: 34px;
3020
+ padding: 0;
3021
+ border: 1px solid rgba(255, 255, 255, 0.65);
3022
+ border-radius: 50%;
3023
+ background: var(--accent);
3024
+ color: #fff;
3025
+ box-shadow:
3026
+ 0 1px 2px rgba(89, 58, 32, 0.18),
3027
+ 0 8px 22px rgba(184, 92, 55, 0.28),
3028
+ inset 0 1px 0 rgba(255, 255, 255, 0.25);
2986
3029
  cursor: pointer;
2987
3030
  z-index: 13;
2988
3031
  opacity: 0;
2989
- transform: translateY(12px) scale(0.92);
3032
+ transform: translateY(10px) scale(0.86);
2990
3033
  pointer-events: none;
2991
- transition: opacity 0.28s var(--ease-out-expo), transform 0.28s var(--ease-out-expo), background 0.18s ease, border-color 0.18s ease;
3034
+ transition:
3035
+ opacity 0.26s var(--ease-out-expo),
3036
+ transform 0.32s var(--ease-spring),
3037
+ background 0.18s ease,
3038
+ border-color 0.18s ease,
3039
+ box-shadow 0.2s ease;
2992
3040
  }
2993
3041
 
2994
3042
  .chat-jump-bottom.visible {
@@ -2997,13 +3045,36 @@
2997
3045
  pointer-events: auto;
2998
3046
  }
2999
3047
 
3048
+ .chat-jump-bottom svg {
3049
+ transition: transform 0.2s var(--ease-out-expo);
3050
+ }
3051
+
3000
3052
  .chat-jump-bottom:hover {
3001
- background: rgba(255, 255, 255, 0.98);
3002
- border-color: rgba(197, 101, 61, 0.24);
3053
+ background: var(--accent-hover);
3054
+ border-color: rgba(255, 255, 255, 0.85);
3055
+ box-shadow:
3056
+ 0 2px 4px rgba(89, 58, 32, 0.20),
3057
+ 0 12px 28px rgba(184, 92, 55, 0.38),
3058
+ inset 0 1px 0 rgba(255, 255, 255, 0.32);
3059
+ }
3060
+
3061
+ .chat-jump-bottom:hover svg {
3062
+ transform: translateY(1.5px);
3003
3063
  }
3004
3064
 
3005
3065
  .chat-jump-bottom:active {
3006
- transform: translateY(1px);
3066
+ transform: translateY(0) scale(0.92);
3067
+ background: var(--accent-active);
3068
+ transition-duration: 0.08s;
3069
+ }
3070
+
3071
+ .chat-jump-bottom:focus-visible {
3072
+ outline: none;
3073
+ box-shadow:
3074
+ 0 2px 4px rgba(89, 58, 32, 0.20),
3075
+ 0 12px 28px rgba(184, 92, 55, 0.38),
3076
+ inset 0 1px 0 rgba(255, 255, 255, 0.32),
3077
+ 0 0 0 3px rgba(197, 101, 61, 0.32);
3007
3078
  }
3008
3079
 
3009
3080
  .chat-container.active { display: flex; }
@@ -6720,12 +6791,11 @@
6720
6791
  /* 回到底部按钮 - 紧凑 */
6721
6792
  .terminal-jump-bottom,
6722
6793
  .chat-jump-bottom {
6723
- height: 28px;
6724
- min-width: 64px;
6725
- padding: 0 10px;
6726
- font-size: 0.6875rem;
6794
+ width: 32px;
6795
+ height: 32px;
6796
+ padding: 0;
6727
6797
  right: 10px;
6728
- bottom: 14px;
6798
+ bottom: 12px;
6729
6799
  }
6730
6800
 
6731
6801
  /* 小键盘 FAB */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.17.5",
3
+ "version": "1.18.0",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {