@wendongfly/myhi 1.3.191 → 1.3.193

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.
package/dist/index.html CHANGED
@@ -13,8 +13,9 @@
13
13
  <script src="/lib/machine.js"></script>
14
14
  <script src="/lib/prefs.js"></script>
15
15
  <script src="/sio-client/socket.io.min.js"></script>
16
+ <script src="/theme.js"></script>
16
17
  <script>
17
- (function(){try{var t=localStorage.getItem('myhi_theme')||'dark',f=localStorage.getItem('myhi_font')||'medium',r=document.documentElement;if(t&&t!=='dark')r.setAttribute('data-theme',t);r.setAttribute('data-font',f);}catch(e){}})();
18
+ (function(){try{var t=localStorage.getItem('myhi_theme')||window.MYHI_DEFAULT_THEME||'dark',f=localStorage.getItem('myhi_font')||'medium',r=document.documentElement;if(t&&t!=='dark')r.setAttribute('data-theme',t);r.setAttribute('data-font',f);}catch(e){}})();
18
19
  </script>
19
20
  <style>
20
21
  :root {
@@ -36,8 +37,15 @@
36
37
  html[data-font="small"]{font-size:15px;} html[data-font="medium"]{font-size:17px;} html[data-font="large"]{font-size:19.5px;}
37
38
  * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
38
39
 
40
+ /* 整页滚动(原来只有中间 640px 的列表在滚:大屏上鼠标在两侧空白处滚不动,系统白色粗滚动条还贴着卡片)。
41
+ 顶栏 + 搜索区吸顶,滚动条回到浏览器最右,改成细的深色样式 */
42
+ html { scrollbar-width: thin; scrollbar-color: var(--border2) transparent; }
43
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
44
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
45
+ ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
46
+ ::-webkit-scrollbar-track { background: transparent; }
39
47
  html, body {
40
- height: 100%;
48
+ min-height: 100%;
41
49
  background: var(--bg);
42
50
  color: var(--text);
43
51
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@@ -48,10 +56,11 @@
48
56
  #app {
49
57
  display: flex;
50
58
  flex-direction: column;
51
- height: 100%;
59
+ min-height: 100vh;
52
60
  max-width: 640px;
53
61
  margin: 0 auto;
54
62
  }
63
+ #view-controls { position: sticky; top: var(--hd-h, 57px); z-index: 9; background: var(--bg); padding-bottom: 0.4rem !important; }
55
64
 
56
65
  /* ── Header ── */
57
66
  #header {
@@ -169,6 +178,107 @@
169
178
  .ai-banner button:disabled { opacity: 0.6; cursor: default; }
170
179
  .ai-banner.ok { border-color: rgba(63,185,80,0.45); background: rgba(63,185,80,0.1); }
171
180
  .ai-banner.err { border-color: rgba(248,81,73,0.45); background: rgba(248,81,73,0.08); }
181
+ /* ── 电脑端工作台(≥1024px,body.ws):左侧任务栏 + 右侧嵌入当前任务会话(仿 DeepSeek)。手机/窄屏不变 ── */
182
+ #ws-main, #ws-foot, #ws-collapse, #ws-rail { display: none; }
183
+ body.ws { overflow: hidden; }
184
+ body.ws #app { position: fixed; left: 0; top: 0; bottom: 0; width: 290px; max-width: none; min-height: 0; margin: 0;
185
+ border-right: 1px solid var(--border); background: var(--surface); overflow: hidden; display: flex; flex-direction: column; }
186
+ body.ws #header { background: var(--surface); padding-top: 0.8rem; padding-bottom: 0.6rem; border-bottom: none; }
187
+ body.ws .hd-actions > :not(#conn-dot) { display: none !important; }
188
+ body.ws #ws-collapse { display: block; }
189
+ body.ws #view-controls { position: static; background: var(--surface); padding-top: 0 !important; }
190
+ body.ws #view-controls > div:first-child { flex-direction: column-reverse; align-items: stretch; }
191
+ body.ws #new-btn { display: block !important; width: 100%; padding: 0.55rem; }
192
+ body.ws #search-count { display: none; }
193
+ body.ws #sess-search { flex: none; width: 100%; }
194
+ body.ws #header { justify-content: flex-start; }
195
+ body.ws #ws-collapse { margin-left: auto; }
196
+ body.ws #list { flex: 1; overflow-y: auto; padding: 0.3rem 0.5rem 1rem; scrollbar-width: thin; }
197
+ body.ws .card { padding: 0.45rem 0.55rem; gap: 0.55rem; margin-bottom: 2px; border: 1px solid transparent; background: transparent; border-radius: 8px; }
198
+ body.ws .card:hover { background: var(--bg); }
199
+ body.ws .card.active { background: var(--bg); border-color: var(--border); }
200
+ body.ws .card.active .card-title { color: var(--accent); }
201
+ body.ws .avatar { width: 26px; height: 26px; border-radius: 7px; font-size: 0.78rem; }
202
+ body.ws .card-title { font-size: 0.84rem; font-weight: 500; }
203
+ body.ws .card-sub { font-size: 0.68rem; margin-top: 0.05rem; }
204
+ body.ws .card-gist, body.ws .card-tags { display: none !important; }
205
+ body.ws .group-header { margin: 0.6rem 0.3rem 0.2rem !important; }
206
+ body.ws #fab, body.ws #fab-note, body.ws #fab-tip { display: none !important; }
207
+ body.ws #ws-foot { display: flex; align-items: center; gap: 0.4rem; padding: 0.55rem 0.6rem 0.6rem 0.9rem; }
208
+ #ws-user { flex: 1; min-width: 0; font-size: 0.8rem; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
209
+ #ws-set { position: relative; background: none; border: none; color: var(--text2); font-size: 1.05rem; width: 34px; height: 34px; border-radius: 8px; cursor: pointer; }
210
+ #ws-set:hover, #ws-set.on { background: var(--bg); color: var(--text); }
211
+ #ws-set.has-dot::after, #ws-menu-dot.on { content: ''; position: absolute; top: 6px; right: 6px; width: 7px; height: 7px; border-radius: 50%; background: var(--red); }
212
+ #ws-menu { display: none; position: fixed; z-index: 45; min-width: 12rem; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.4); padding: 0.3rem; }
213
+ #ws-menu.open { display: block; }
214
+ #ws-menu button { position: relative; display: flex; align-items: baseline; gap: 0.5rem; width: 100%; text-align: left; background: none; border: none; color: var(--text); font-size: 0.84rem; padding: 0.5rem 0.6rem; border-radius: 7px; cursor: pointer; }
215
+ #ws-menu button:hover { background: var(--bg); }
216
+ #ws-menu button small { margin-left: auto; font-size: 0.68rem; color: var(--muted); }
217
+ #ws-menu button.danger { color: var(--red); }
218
+ #ws-menu hr { border: none; border-top: 1px solid var(--border); margin: 0.3rem 0.2rem; }
219
+ #ws-menu-dot.on { position: static; display: inline-block; margin-left: 0.3rem; }
220
+ #ws-collapse { background: none; border: none; color: var(--muted); font-size: 1rem; cursor: pointer; padding: 0.2rem 0.4rem; border-radius: 6px; }
221
+ #ws-collapse:hover { background: var(--bg); color: var(--text); }
222
+ body.ws #ws-main { display: block; position: fixed; left: 290px; top: 0; right: 0; bottom: 0; background: var(--bg); }
223
+ #ws-main iframe { width: 100%; height: 100%; border: none; display: block; }
224
+ #ws-welcome { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; padding: 2rem; color: var(--muted); text-align: center; }
225
+ #ws-welcome .logo { font-size: 2.2rem; }
226
+ #ws-welcome .ai-banner { margin: 0; max-width: 520px; text-align: left; }
227
+ #ws-welcome button.ws-new { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: 0.7rem 1.6rem; font-size: 0.95rem; font-weight: 600; cursor: pointer; }
228
+ /* 收起任务栏 → 留一条 56px 窄栏(仿 VS Code / DeepSeek):展开、新建、最近任务头像(悬停看名字、点了直接切)、设置。
229
+ 原来收起后只剩左下角一个小「» 任务」,还压在快捷栏上,普通用户根本发现不了 */
230
+ body.ws.ws-collapsed #app { display: none; }
231
+ body.ws.ws-collapsed #ws-main { left: 56px; }
232
+ body.ws.ws-collapsed #ws-rail { display: flex; position: fixed; left: 0; top: 0; bottom: 0; width: 56px; flex-direction: column; align-items: center;
233
+ gap: 0.35rem; padding: 0.7rem 0 0.6rem; background: var(--surface); border-right: 1px solid var(--border); z-index: 20; }
234
+ #ws-rail .rb { position: relative; width: 38px; height: 38px; border-radius: 10px; border: none; background: none; color: var(--text2); font-size: 1.05rem;
235
+ cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
236
+ #ws-rail .rb:hover { background: var(--bg); color: var(--text); }
237
+ #ws-rail .rb.new { background: var(--accent); color: #fff; font-size: 1.3rem; }
238
+ #ws-rail .rb.has-dot::after { content: ''; position: absolute; top: 5px; right: 5px; width: 7px; height: 7px; border-radius: 50%; background: var(--red); }
239
+ #ws-rail .rail-sep { width: 28px; border-top: 1px solid var(--border); margin: 0.2rem 0; flex-shrink: 0; }
240
+ #ws-rail .rail-list { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none; display: flex; flex-direction: column; align-items: center; gap: 0.35rem; padding: 0.1rem 0; }
241
+ #ws-rail .rail-list::-webkit-scrollbar { display: none; }
242
+ #ws-rail .ra { position: relative; width: 34px; height: 34px; border-radius: 9px; color: #fff; font-weight: 700; font-size: 0.85rem; border: 2px solid transparent;
243
+ display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; font-family: 'SF Mono', Consolas, monospace; }
244
+ #ws-rail .ra:hover { filter: brightness(1.15); }
245
+ #ws-rail .ra.active { border-color: var(--text); box-shadow: 0 0 0 2px var(--bg); }
246
+ #ws-rail .ra.busy::after { content: ''; position: absolute; top: -3px; right: -3px; width: 9px; height: 9px; border-radius: 50%; background: var(--accent);
247
+ border: 2px solid var(--surface); animation: stPulse 1.2s ease-in-out infinite; }
248
+ #ws-rail .rail-more { font-size: 0.68rem; color: var(--muted); cursor: pointer; padding: 0.2rem; }
249
+ /* 窄栏悬停提示(原生 title 有延迟,这里即时显示) */
250
+ #ws-tip { position: fixed; z-index: 60; left: 62px; background: var(--text); color: var(--bg); font-size: 0.75rem; padding: 0.3rem 0.55rem; border-radius: 6px;
251
+ white-space: nowrap; pointer-events: none; display: none; max-width: 320px; overflow: hidden; text-overflow: ellipsis; }
252
+ /* 任务操作:⋯ 按钮(电脑上悬停才出现)+ 小菜单 / 原地改名 / 标签编辑 */
253
+ .card-more { display: none; background: none; border: none; color: var(--muted); font-size: 1.05rem; line-height: 1; padding: 0.15rem 0.35rem; border-radius: 6px; cursor: pointer; }
254
+ .card-more:hover, .card-more.on { background: var(--border); color: var(--text); }
255
+ @media (hover: hover) and (pointer: fine) { .card:hover .card-more, .card-more.on { display: block; } }
256
+ #card-menu:not(.sheet-mode), #tag-pop:not(.sheet-mode) { position: fixed; z-index: 85; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.35); padding: 0.3rem; }
257
+ #card-menu:not(.sheet-mode) { min-width: 10.5rem; }
258
+ #card-menu button { display: block; width: 100%; text-align: left; background: none; border: none; color: var(--text); font-size: 0.84rem; padding: 0.5rem 0.65rem; border-radius: 7px; cursor: pointer; }
259
+ #card-menu button:hover { background: var(--bg); }
260
+ #card-menu button.danger { color: var(--red); }
261
+ #card-menu.sheet-mode, #tag-pop.sheet-mode { position: fixed; inset: auto 0 0 0; z-index: 85; }
262
+ #card-menu.sheet-mode { inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: flex-end; justify-content: center; }
263
+ #card-menu .cm-box { background: var(--surface); border-radius: 18px 18px 0 0; width: 100%; max-width: 640px; padding: 0.5rem 0 max(1rem, env(safe-area-inset-bottom)); }
264
+ #card-menu .cm-title { padding: 0.6rem 1.25rem 0.4rem; color: var(--muted); font-size: 0.8rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
265
+ #card-menu.sheet-mode button { font-size: 0.95rem; padding: 0.9rem 1.25rem; border-radius: 0; border-top: 1px solid var(--border); }
266
+ #card-menu .cm-cancel { color: var(--muted) !important; text-align: center !important; border-top: 6px solid var(--bg) !important; }
267
+ .rename-inp { width: 100%; font-size: 0.9rem; font-weight: 600; padding: 0.15rem 0.35rem; border: 1px solid var(--accent); border-radius: 6px; background: var(--bg); color: var(--text); outline: none; }
268
+ #tag-pop:not(.sheet-mode) { width: 17rem; padding: 0.7rem; }
269
+ #tag-pop.sheet-mode { background: var(--surface); border-radius: 18px 18px 0 0; padding: 1rem 1rem max(1rem, env(safe-area-inset-bottom)); box-shadow: 0 -8px 30px rgba(0,0,0,0.35); max-width: 640px; margin: 0 auto; }
270
+ #tag-pop .tp-title { font-size: 0.8rem; font-weight: 600; color: var(--text2); margin-bottom: 0.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
271
+ #tag-pop .tp-cur, #tag-pop .tp-all { display: flex; flex-wrap: wrap; gap: 0.3rem; }
272
+ #tag-pop .tp-chip { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.75rem; background: rgba(124,58,237,0.14); border: 1px solid rgba(124,58,237,0.35); border-radius: 999px; padding: 0.1rem 0.25rem 0.1rem 0.55rem; }
273
+ #tag-pop .tp-chip b { cursor: pointer; color: var(--muted); font-weight: 400; padding: 0 0.2rem; }
274
+ #tag-pop .tp-chip b:hover { color: var(--red); }
275
+ #tag-pop .tp-none { font-size: 0.74rem; color: var(--muted); }
276
+ #tag-pop .tp-inp { width: 100%; margin: 0.6rem 0 0.2rem; font-size: 0.82rem; padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); outline: none; }
277
+ #tag-pop .tp-inp:focus { border-color: var(--accent); }
278
+ #tag-pop .tp-sub { font-size: 0.7rem; color: var(--muted); margin: 0.45rem 0 0.3rem; }
279
+ #tag-pop .tp-sug { font-size: 0.74rem; color: var(--text2); border: 1px dashed var(--border); border-radius: 999px; padding: 0.1rem 0.5rem; cursor: pointer; }
280
+ #tag-pop .tp-sug:hover { border-color: var(--accent); color: var(--accent); }
281
+ #tag-pop .tp-done { display: block; margin: 0.65rem 0 0 auto; background: var(--accent); color: #fff; border: none; border-radius: 7px; padding: 0.35rem 0.9rem; font-size: 0.78rem; cursor: pointer; }
172
282
  /* 当前使用模式(单用户 / 多用户·管理员 / 多用户·成员),让成员知道为什么有些项是只读 */
173
283
  .set-mode { font-size: 0.72rem; color: var(--muted); margin: 0.35rem 0.25rem 0; }
174
284
  .set-mode a { color: var(--accent); text-decoration: none; }
@@ -184,11 +294,10 @@
184
294
  /* ── Session list ── */
185
295
  #list {
186
296
  flex: 1;
187
- overflow-y: auto;
188
- -webkit-overflow-scrolling: touch;
189
- padding: 0.75rem 0.75rem 6rem;
297
+ padding: 0.5rem 0.75rem 6rem;
190
298
  }
191
299
 
300
+
192
301
  .empty-state {
193
302
  display: flex;
194
303
  flex-direction: column;
@@ -213,7 +322,7 @@
213
322
  display: flex;
214
323
  align-items: center;
215
324
  gap: 0.85rem;
216
- padding: 0.85rem 0.9rem;
325
+ padding: 0.7rem 0.85rem;
217
326
  background: var(--surface);
218
327
  border: 1px solid var(--border);
219
328
  border-radius: 12px;
@@ -226,10 +335,10 @@
226
335
  .card:active { background: #1c2230; }
227
336
 
228
337
  .avatar {
229
- width: 44px; height: 44px;
338
+ width: 38px; height: 38px;
230
339
  border-radius: 10px;
231
340
  display: flex; align-items: center; justify-content: center;
232
- font-size: 1.3rem;
341
+ font-size: 1.1rem;
233
342
  font-weight: 700;
234
343
  flex-shrink: 0;
235
344
  font-family: 'SF Mono', Consolas, monospace;
@@ -240,7 +349,10 @@
240
349
  flex: 1;
241
350
  min-width: 0;
242
351
  }
352
+ .card-title-row { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
353
+ .card-title-row .card-tags { display: flex; gap: 0.25rem; flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; }
243
354
  .card-title {
355
+ flex: 0 1 auto; min-width: 0;
244
356
  font-size: 0.95rem;
245
357
  font-weight: 600;
246
358
  white-space: nowrap;
@@ -279,6 +391,12 @@
279
391
  }
280
392
  .status-dot.alive { background: var(--green); box-shadow: 0 0 5px var(--green); }
281
393
  .status-dot.dead { background: var(--red); }
394
+ /* 任务状态:执行中(AI 正在跑,跳动)/ 已停止;空闲不显示,免得一排绿点看不出谁在干活 */
395
+ .st { font-size: 0.68rem; white-space: nowrap; display: flex; align-items: center; gap: 0.3rem; }
396
+ .st-busy { color: var(--accent); font-weight: 600; }
397
+ .st-busy::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: stPulse 1.2s ease-in-out infinite; }
398
+ @keyframes stPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } }
399
+ .st-dead { color: var(--muted); }
282
400
  .viewers {
283
401
  font-size: 0.7rem;
284
402
  color: var(--muted);
@@ -612,6 +730,7 @@
612
730
  <div id="app">
613
731
  <div id="header">
614
732
  <div class="logo">my<span>hi</span></div>
733
+ <button id="ws-collapse" onclick="wsSetCollapsed(true)" title="收起任务栏(收起后左侧保留窄栏)">«</button>
615
734
  <div class="hd-actions">
616
735
  <button id="grid-btn" class="hd-btn" onclick="location.href='/grid'" title="分屏工作台(多任务并排)">▦<span class="hd-txt"> 分屏</span></button>
617
736
  <button id="doctor-btn" class="hd-btn" onclick="openDoctor()" title="会话体检">🩺<span class="hd-txt"> 体检</span></button>
@@ -655,6 +774,42 @@
655
774
  <small>以后随时点右下角的 + 新建</small>
656
775
  </div>
657
776
  </div>
777
+ <!-- 工作台任务栏底部:平时只留一行「用户 + ⚙」,其余功能收进 ⚙ 菜单(功能再多也不占地方) -->
778
+ <div id="ws-foot">
779
+ <span id="ws-user">👤 本机</span>
780
+ <button id="ws-set" onclick="toggleWsMenu(event)" title="设置与更多">⚙</button>
781
+ </div>
782
+ </div>
783
+
784
+ <!-- 电脑端工作台:任务栏底部功能区 + 右侧会话区(body.ws 时显示) -->
785
+ <div id="ws-main"><div id="ws-welcome">
786
+ <div class="logo">my<span>hi</span></div>
787
+ <div>在左边选一个任务继续,或者新建一个</div>
788
+ <button class="ws-new" onclick="openSheet()">+ 新建任务</button>
789
+ </div></div>
790
+ <!-- 收起后的窄栏 -->
791
+ <div id="ws-rail">
792
+ <button class="rb" onclick="wsSetCollapsed(false)" data-tip="展开任务栏">☰</button>
793
+ <button class="rb new" onclick="openSheet()" data-tip="新建任务">+</button>
794
+ <div class="rail-sep"></div>
795
+ <div class="rail-list" id="rail-list"></div>
796
+ <div class="rail-sep"></div>
797
+ <button class="rb" id="rail-set" onclick="toggleWsMenu(event)" data-tip="设置与更多">⚙</button>
798
+ </div>
799
+ <div id="ws-tip"></div>
800
+ <!-- ⚙ 菜单(工作台底部 / 窄栏的 ⚙ 打开) -->
801
+ <div id="ws-menu">
802
+ <button onclick="wsMenuGo(openSettings)">⚙ 设置<span id="ws-menu-dot"></span></button>
803
+ <button onclick="wsMenuGo(() => location.href = '/grid')">▦ 分屏<small>多任务并排</small></button>
804
+ <button onclick="wsMenuGo(openNoteSheet)">📥 随手记<small>先记进草稿箱</small></button>
805
+ <button onclick="wsMenuGo(openDoctor)">🩺 会话体检</button>
806
+ <button onclick="wsMenuGo(showUsage)">📊 用量</button>
807
+ <button onclick="wsMenuGo(openHelpSheet)">❓ 帮助</button>
808
+ <div id="ws-menu-acct">
809
+ <hr>
810
+ <button id="ws-menu-pw" onclick="wsMenuGo(openPasswdSheet)" style="display:none">🔑 修改密码</button>
811
+ <button class="danger" onclick="wsMenuGo(doLogout)">↩ 退出登录</button>
812
+ </div>
658
813
  </div>
659
814
 
660
815
  <!-- FAB -->
@@ -684,6 +839,7 @@
684
839
  <div class="hs-tile"><span>🛡</span><div><b>护栏 / 密钥</b><p>敏感信息不外泄</p></div></div>
685
840
  <div class="hs-tile"><span>⚙️</span><div><b>会话设置</b><p>快捷栏、折叠、主题字号</p></div></div>
686
841
  </div>
842
+ <div class="hs-tip" style="margin-bottom:0.45rem">💡 进入任务后点顶栏 <kbd>?</kbd> 可看每个按钮是做什么的(模型、计划、压缩、清除…);电脑上鼠标停在按钮上也有提示。</div>
687
843
  <div class="hs-tip">📱 手机浏览器打开同一地址即可(局域网 / Tailscale),建议「添加到主屏幕」当 App 用。</div>
688
844
  <div class="hs-actions">
689
845
  <button class="hs-ghost" onclick="closeHelpSheet()">我知道了</button>
@@ -906,6 +1062,7 @@
906
1062
  document.getElementById('user-name').textContent = data.name;
907
1063
  document.getElementById('um-name').textContent = data.name;
908
1064
  document.getElementById('user-btn').style.display = '';
1065
+ document.getElementById('ws-user').textContent = '👤 ' + data.name;
909
1066
  fitHeader();
910
1067
  }
911
1068
  // 成员用户(users.json 登录)才显示自助改密入口;管理员改密码在 /admin
@@ -917,7 +1074,9 @@
917
1074
  if (!_isAdmin) document.querySelectorAll('#set-svc .admin-only').forEach(el => el.remove());
918
1075
  checkAiEngine();
919
1076
  checkHomeVersion();
920
- if (data.hasUsers && data.role === 'operator') document.getElementById('passwd-btn').style.display = '';
1077
+ if (data.hasUsers && data.role === 'operator') { document.getElementById('passwd-btn').style.display = ''; document.getElementById('ws-menu-pw').style.display = ''; }
1078
+ // 单用户模式没有「登录账号」可退:⚙ 菜单不显示账户段
1079
+ if (!(data.exclusive || data.hasUsers)) document.getElementById('ws-menu-acct').style.display = 'none';
921
1080
  if (data.dir) {
922
1081
  _userDir = data.dir;
923
1082
  // 默认填入绑定目录,隐藏盘符栏,保留最近目录
@@ -1026,9 +1185,14 @@
1026
1185
  e.stopPropagation();
1027
1186
  const m = document.getElementById('user-menu');
1028
1187
  if (m.classList.contains('open')) return closeUserMenu();
1029
- const r = document.getElementById('user-btn').getBoundingClientRect();
1030
- m.style.top = (r.bottom + 6) + 'px';
1031
- m.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
1188
+ const r = (e.currentTarget || document.getElementById('user-btn')).getBoundingClientRect();
1189
+ if (r.bottom > innerHeight - 220) { // 工作台任务栏底部的用户按钮:往上弹
1190
+ m.style.top = ''; m.style.bottom = (innerHeight - r.top + 6) + 'px';
1191
+ m.style.left = r.left + 'px'; m.style.right = 'auto';
1192
+ } else {
1193
+ m.style.bottom = ''; m.style.top = (r.bottom + 6) + 'px';
1194
+ m.style.left = 'auto'; m.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
1195
+ }
1032
1196
  m.classList.add('open');
1033
1197
  };
1034
1198
  window.closeUserMenu = function() { document.getElementById('user-menu').classList.remove('open'); };
@@ -1047,7 +1211,7 @@
1047
1211
  // ── 外观:主题 & 字号(与会话页共用 localStorage myhi_theme / myhi_font)──
1048
1212
  const THEME_META = { dark: '#0d1117', light: '#ffffff', black: '#000000', system: '#0d1117' };
1049
1213
  function applyAppearance() {
1050
- const t = localStorage.getItem('myhi_theme') || 'dark';
1214
+ const t = localStorage.getItem('myhi_theme') || window.MYHI_DEFAULT_THEME || 'dark';
1051
1215
  const f = localStorage.getItem('myhi_font') || 'medium';
1052
1216
  const r = document.documentElement;
1053
1217
  if (!t || t === 'dark') r.removeAttribute('data-theme'); else r.setAttribute('data-theme', t);
@@ -1059,6 +1223,7 @@
1059
1223
  // 顶栏按钮组折行了就逐级收紧:字号「大」时按钮跟着 rem 变宽,固定断点兜不住
1060
1224
  function fitHeader() {
1061
1225
  const h = document.getElementById('header');
1226
+ requestAnimationFrame(() => document.documentElement.style.setProperty('--hd-h', h.offsetHeight + 'px')); // 搜索区吸顶在顶栏下方
1062
1227
  const acts = h.querySelector('.hd-actions');
1063
1228
  const wraps = () => acts.offsetHeight > document.getElementById('settings-btn').offsetHeight * 1.5;
1064
1229
  h.classList.remove('hd-fit1', 'hd-fit2');
@@ -1072,7 +1237,7 @@
1072
1237
  const THEMES = [['system','跟随系统'],['dark','深色'],['light','浅色'],['black','纯黑']];
1073
1238
  const FONTS = [['small','小'],['medium','中'],['large','大']];
1074
1239
  function renderAppearance() {
1075
- const t = localStorage.getItem('myhi_theme') || 'dark';
1240
+ const t = localStorage.getItem('myhi_theme') || window.MYHI_DEFAULT_THEME || 'dark';
1076
1241
  const f = localStorage.getItem('myhi_font') || 'medium';
1077
1242
  const seg = (key, opts, cur) => opts.map(([v, l]) => `<button data-k="${key}" data-v="${v}" class="${v === cur ? 'on' : ''}">${l}</button>`).join('');
1078
1243
  document.getElementById('set-theme').innerHTML = seg('myhi_theme', THEMES, t);
@@ -1210,6 +1375,9 @@
1210
1375
  btn.style.display = _homeVer.updateAvailable ? '' : 'none';
1211
1376
  btn.textContent = '升级到 v' + _homeVer.latest;
1212
1377
  document.getElementById('settings-btn').classList.toggle('has-dot', !!_homeVer.updateAvailable);
1378
+ document.getElementById('ws-set').classList.toggle('has-dot', !!_homeVer.updateAvailable);
1379
+ document.getElementById('ws-menu-dot').classList.toggle('on', !!_homeVer.updateAvailable);
1380
+ document.getElementById('rail-set').classList.toggle('has-dot', !!_homeVer.updateAvailable);
1213
1381
  } catch {}
1214
1382
  }
1215
1383
  // 等升级落地:轮询 /api/version(重启/停服期间连不上是正常的)。成功 = 版本变了(且等于目标);
@@ -1340,7 +1508,13 @@
1340
1508
 
1341
1509
  // 视图状态:分组方式 + 标签筛选
1342
1510
  let _allSessions = [];
1343
- let _groupMode = localStorage.getItem('myhi_group_mode') || 'time'; // time | project | tag
1511
+ let _renaming = null, _renderPending = false; // 原地改名中的任务 id / 期间被推迟的刷新
1512
+ // 电脑端工作台状态(声明放这里:列表渲染会用到,必须先于任何一次渲染)
1513
+ let _wsCur = null; // 右侧当前打开的任务
1514
+ let _wsPending = new URLSearchParams(location.search).get('s'); // 刷新后回到上次的任务
1515
+ const _wsFrames = new Map(); // id → iframe,按最近使用排序(最后一个最新)
1516
+ // 电脑端工作台默认按项目分组(任务多了按项目找最快),手机默认按最近;用户切过就按用户的
1517
+ let _groupMode = localStorage.getItem('myhi_group_mode') || (matchMedia('(min-width: 1024px)').matches ? 'project' : 'time'); // time | project | tag
1344
1518
  let _tagFilter = null; // 仅 tag 模式下:只看某标签
1345
1519
  let _search = ''; // 会话搜索关键词(标题/首条需求/目录/标签)
1346
1520
  let _draftCounts = {}; // { sessionId: 待发草稿数 },给卡片打角标
@@ -1388,17 +1562,18 @@
1388
1562
  ? `<div style="font-size:0.66rem;color:#d29922;margin-top:0.2rem">📥 ${nDraft} 条待发草稿</div>`
1389
1563
  : '';
1390
1564
  return `
1391
- <div class="card" data-id="${safeId}" data-title="${safeTitle}">
1565
+ <div class="card${s.id === _wsCur ? ' active' : ''}" data-id="${safeId}" data-title="${safeTitle}">
1392
1566
  <div class="avatar" style="background:${color}">${letter}</div>
1393
1567
  <div class="card-body">
1394
- <div class="card-title">${safeTitle}</div>
1568
+ <div class="card-title-row"><div class="card-title">${safeTitle}</div>${tags ? `<div class="card-tags">${tags}</div>` : ''}</div>
1395
1569
  <div class="card-sub">${sub}</div>
1396
1570
  ${gist}
1397
1571
  ${draftChip}
1398
- ${tags ? `<div class="card-tags" style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-top:0.3rem">${tags}</div>` : ''}
1399
1572
  </div>
1400
1573
  <div class="card-right">
1401
- <div class="status-dot ${s.alive ? 'alive' : 'dead'}"></div>
1574
+ <button class="card-more" title="更多操作(重命名 / 标签 / 终止)">⋯</button>
1575
+ ${!s.alive ? '<div class="st st-dead" title="会话进程已退出,点进去会自动恢复">已停止</div>'
1576
+ : s.busy ? '<div class="st st-busy" title="AI 正在执行">执行中</div>' : ''}
1402
1577
  ${s.viewers > 1 ? `<div class="viewers">👁 ${s.viewers}</div>` : ''}
1403
1578
  </div>
1404
1579
  </div>`;
@@ -1428,6 +1603,8 @@
1428
1603
 
1429
1604
  function renderSessions(sessions) {
1430
1605
  if (Array.isArray(sessions)) _allSessions = sessions;
1606
+ // 正在原地改名:列表每 5 秒刷新一次会把输入框冲掉,先攒着,改完再刷
1607
+ if (_renaming) { _renderPending = true; return; }
1431
1608
  renderTagFilter();
1432
1609
  updateGroupSeg();
1433
1610
 
@@ -1490,6 +1667,7 @@
1490
1667
  html = items.map(cardHTML).join('');
1491
1668
  }
1492
1669
  list.innerHTML = html;
1670
+ wsAfterRender();
1493
1671
 
1494
1672
  // 组头点击折叠/展开(项目多了列表很长)
1495
1673
  list.querySelectorAll('.group-header[data-gkey]').forEach(h => {
@@ -1505,8 +1683,9 @@
1505
1683
  list.querySelectorAll('.card').forEach(card => {
1506
1684
  const id = card.dataset.id;
1507
1685
  const title = card.dataset.title;
1508
- card.addEventListener('click', () => openSession(id));
1686
+ card.addEventListener('click', (e) => { if (!e.target.closest('.card-more, .rename-inp')) openSession(id); });
1509
1687
  card.addEventListener('contextmenu', (e) => showActions(e, id, title));
1688
+ card.querySelector('.card-more')?.addEventListener('click', (e) => { e.stopPropagation(); showActions(e, id, title, e.currentTarget); });
1510
1689
  // 移动端长按
1511
1690
  let lpTimer = null;
1512
1691
  card.addEventListener('touchstart', (e) => { lpTimer = setTimeout(() => showActions(e, id, title), 550); }, { passive: true });
@@ -1576,6 +1755,7 @@
1576
1755
  if (first && !creating) showFabHint();
1577
1756
  };
1578
1757
  function showFabHint() {
1758
+ if (wsOn()) return; // 工作台里「+ 新建任务」就在左上角,不用再指右下角
1579
1759
  const fab = document.getElementById('fab'), tip = document.getElementById('fab-tip');
1580
1760
  fab.classList.add('hint'); tip.classList.add('show');
1581
1761
  const done = () => { fab.classList.remove('hint'); tip.classList.remove('show'); document.removeEventListener('pointerdown', done, true); };
@@ -1662,10 +1842,115 @@
1662
1842
  };
1663
1843
 
1664
1844
  // ── Navigation ──────────────────────────────────────────────
1845
+ // 电脑端工作台:在右侧打开;手机/窄屏:进会话页
1665
1846
  function openSession(id) {
1847
+ if (wsOn()) return wsOpen(id);
1666
1848
  window.location.href = `/terminal/${id}`;
1667
1849
  }
1668
1850
 
1851
+ // ── 电脑端工作台(≥1024px):左侧任务栏 + 右侧嵌入会话页(/terminal/:id?embed=ws)──
1852
+ // 最近打开的 5 个任务的会话页留在后台(隐藏不销毁),来回切换不用重载,滚动位置/没发的输入都还在。
1853
+ const WS_MQ = matchMedia('(min-width: 1024px)');
1854
+ const WS_KEEP = 5;
1855
+ function wsOn() { return document.body.classList.contains('ws'); }
1856
+ function wsOpen(id) {
1857
+ _wsCur = id;
1858
+ let f = _wsFrames.get(id);
1859
+ if (f) _wsFrames.delete(id);
1860
+ else {
1861
+ f = document.createElement('iframe');
1862
+ f.src = '/terminal/' + encodeURIComponent(id) + '?embed=ws';
1863
+ f.setAttribute('allow', 'microphone; camera; clipboard-read; clipboard-write');
1864
+ document.getElementById('ws-main').appendChild(f);
1865
+ }
1866
+ _wsFrames.set(id, f);
1867
+ while (_wsFrames.size > WS_KEEP) { const [oid, of] = _wsFrames.entries().next().value; _wsFrames.delete(oid); of.remove(); }
1868
+ for (const [k, fr] of _wsFrames) fr.style.display = k === id ? '' : 'none';
1869
+ document.getElementById('ws-welcome').style.display = 'none';
1870
+ history.replaceState(null, '', '/?s=' + encodeURIComponent(id));
1871
+ const s = _allSessions.find(x => x.id === id);
1872
+ document.title = (s?.title || 'myhi') + ' — myhi';
1873
+ wsMarkActive();
1874
+ setTimeout(() => f.contentWindow?.focus(), 50);
1875
+ }
1876
+ function wsShowWelcome() {
1877
+ _wsCur = null;
1878
+ for (const fr of _wsFrames.values()) fr.style.display = 'none';
1879
+ document.getElementById('ws-welcome').style.display = '';
1880
+ history.replaceState(null, '', '/');
1881
+ document.title = 'myhi';
1882
+ wsMarkActive();
1883
+ }
1884
+ function wsMarkActive() {
1885
+ document.querySelectorAll('#list .card').forEach(c => c.classList.toggle('active', c.dataset.id === _wsCur));
1886
+ document.querySelectorAll('#rail-list .ra').forEach(c => c.classList.toggle('active', c.dataset.id === _wsCur));
1887
+ }
1888
+ // 收起后的窄栏:最近的任务头像(最多 20 个,按最近活动),执行中带跳点,点了直接切
1889
+ const RAIL_MAX = 20;
1890
+ function renderRail() {
1891
+ const el = document.getElementById('rail-list');
1892
+ if (!el) return;
1893
+ const items = [..._allSessions].sort((a, b) => sessionTime(b) - sessionTime(a));
1894
+ el.innerHTML = items.slice(0, RAIL_MAX).map(s =>
1895
+ `<div class="ra${s.id === _wsCur ? ' active' : ''}${s.busy ? ' busy' : ''}" data-id="${esc(s.id)}" style="background:${avatarColor(s.title)}"` +
1896
+ ` data-tip="${esc(s.title || '')}${s.busy ? ' · 执行中' : !s.alive ? ' · 已停止' : ''}">${esc((s.title || '?')[0].toUpperCase())}</div>`).join('') +
1897
+ (items.length > RAIL_MAX ? `<div class="rail-more" data-tip="展开任务栏看全部 ${items.length} 个任务" onclick="wsSetCollapsed(false)">+${items.length - RAIL_MAX}</div>` : '');
1898
+ el.querySelectorAll('.ra').forEach(a => a.onclick = () => wsOpen(a.dataset.id));
1899
+ }
1900
+ // 窄栏即时悬停提示
1901
+ document.addEventListener('mouseover', (e) => {
1902
+ const t = e.target.closest('#ws-rail [data-tip]'), tip = document.getElementById('ws-tip');
1903
+ if (!t) { tip.style.display = 'none'; return; }
1904
+ const r = t.getBoundingClientRect();
1905
+ tip.textContent = t.dataset.tip;
1906
+ tip.style.top = (r.top + r.height / 2 - 13) + 'px';
1907
+ tip.style.display = 'block';
1908
+ });
1909
+ // 每次列表重绘后:刷新前打开的任务等列表到了再打开;当前任务被删了就回欢迎页、回收它的会话页
1910
+ function wsAfterRender() {
1911
+ if (!wsOn()) return;
1912
+ if (_wsPending && _allSessions.some(x => x.id === _wsPending)) { const id = _wsPending; _wsPending = null; wsOpen(id); return; }
1913
+ for (const [k, fr] of _wsFrames) if (!_allSessions.some(x => x.id === k)) { fr.remove(); _wsFrames.delete(k); if (k === _wsCur) wsShowWelcome(); }
1914
+ renderRail();
1915
+ wsMarkActive();
1916
+ }
1917
+ // ⚙ 菜单:从底部 / 窄栏的 ⚙ 往上弹,点外面或 Esc 关闭
1918
+ window.toggleWsMenu = function(e) {
1919
+ e.stopPropagation();
1920
+ const m = document.getElementById('ws-menu');
1921
+ if (m.classList.contains('open')) return closeWsMenu();
1922
+ const r = e.currentTarget.getBoundingClientRect();
1923
+ m.classList.add('open');
1924
+ // 右边缘对齐 ⚙、往左展开,留在任务栏范围内,不压右侧会话的输入框(窄栏的 ⚙ 在最左,就贴左边)
1925
+ m.style.left = Math.max(8, r.right - m.offsetWidth) + 'px';
1926
+ m.style.bottom = (innerHeight - r.top + 6) + 'px';
1927
+ document.getElementById('ws-set').classList.add('on');
1928
+ };
1929
+ function closeWsMenu() { document.getElementById('ws-menu').classList.remove('open'); document.getElementById('ws-set').classList.remove('on'); }
1930
+ window.wsMenuGo = function(fn) { closeWsMenu(); fn(); };
1931
+ document.addEventListener('click', (e) => { if (!e.target.closest('#ws-menu')) closeWsMenu(); });
1932
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeWsMenu(); });
1933
+
1934
+ window.wsSetCollapsed = function(on) {
1935
+ document.body.classList.toggle('ws-collapsed', on);
1936
+ localStorage.setItem('myhi_ws_collapsed', on ? '1' : '');
1937
+ document.getElementById('ws-tip').style.display = 'none';
1938
+ if (on) renderRail();
1939
+ };
1940
+ function applyWs() {
1941
+ const on = WS_MQ.matches && !new URLSearchParams(location.search).get('embed');
1942
+ document.body.classList.toggle('ws', on);
1943
+ document.body.classList.toggle('ws-collapsed', on && !!localStorage.getItem('myhi_ws_collapsed'));
1944
+ // 「AI 引擎没装」提示:工作台里放到右侧欢迎页,手机上放列表上方
1945
+ const banner = document.getElementById('ai-banner');
1946
+ const target = on ? document.getElementById('ws-welcome') : document.getElementById('view-controls');
1947
+ if (on) target.appendChild(banner); else target.parentNode.insertBefore(banner, target);
1948
+ if (!on && _wsCur) { location.href = '/terminal/' + encodeURIComponent(_wsCur); return; } // 窗口拉窄:直接进当前任务的会话页
1949
+ fitHeader();
1950
+ }
1951
+ WS_MQ.addEventListener('change', applyWs);
1952
+ applyWs();
1953
+
1669
1954
  // ── 会话体检 ────────────────────────────────────────────────
1670
1955
  window.openDoctor = function() {
1671
1956
  document.getElementById('doctor-menu').style.display = 'block';
@@ -2056,38 +2341,131 @@
2056
2341
  // ── Kill ─────────────────────────────────────────────────────
2057
2342
  let _killId = null;
2058
2343
  // 会话操作菜单:打开 / 重命名 / 设置标签 / 终止
2059
- function showActions(e, id, title) {
2344
+ // 任务操作菜单:电脑上(细指针)在 ⋯ 按钮或鼠标旁弹小菜单;手机上长按弹底部菜单。
2345
+ // 原来电脑右键也弹手机式底部菜单(离鼠标很远、整屏变暗),重命名 / 标签用浏览器自带 prompt。
2346
+ function showActions(e, id, title, anchor) {
2060
2347
  e.preventDefault();
2061
- const s = _allSessions.find(x => x.id === id);
2062
- const curTags = (s?.tags || []).join(', ');
2348
+ closeCardMenu();
2349
+ const items = [
2350
+ ['✏️ 重命名', () => inlineRename(id)],
2351
+ ['🏷 设置标签', () => openTagEditor(id, anchor || { x: e.clientX, y: e.clientY })],
2352
+ ['↗ 在新窗口打开', () => window.open('/terminal/' + encodeURIComponent(id), '_blank')],
2353
+ ['🗑 终止会话', () => showKill(e, id, title), true],
2354
+ ];
2355
+ if (matchMedia('(pointer: fine)').matches) {
2356
+ const m = document.createElement('div');
2357
+ m.id = 'card-menu';
2358
+ m.innerHTML = items.map(([l, , danger], i) => `<button data-i="${i}" class="${danger ? 'danger' : ''}">${l}</button>`).join('');
2359
+ document.body.appendChild(m);
2360
+ m.querySelectorAll('button').forEach(b => b.onclick = (ev) => { ev.stopPropagation(); closeCardMenu(); items[b.dataset.i][1](); });
2361
+ placePopover(m, anchor || { x: e.clientX, y: e.clientY });
2362
+ if (anchor) anchor.classList.add('on');
2363
+ m._anchor = anchor;
2364
+ return;
2365
+ }
2366
+ // 手机:底部菜单
2063
2367
  const overlay = document.createElement('div');
2064
- overlay.style.cssText = 'position:fixed;inset:0;z-index:80;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
2065
- const box = document.createElement('div');
2066
- box.style.cssText = 'background:var(--surface,var(--surface));border-radius:18px 18px 0 0;width:100%;max-width:640px;padding:0.5rem 0 max(1rem,env(safe-area-inset-bottom))';
2067
- const mkBtn = (label, color) => `<button style="display:block;width:100%;text-align:left;background:none;border:none;border-top:1px solid var(--border,var(--border));color:${color || 'var(--text,var(--text))'};font-size:0.95rem;padding:0.9rem 1.25rem;cursor:pointer">${label}</button>`;
2068
- box.innerHTML =
2069
- `<div style="padding:0.6rem 1.25rem 0.4rem;color:var(--muted,var(--muted));font-size:0.8rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(title)}</div>` +
2070
- mkBtn('打开') + mkBtn('重命名') + mkBtn('设置标签') + mkBtn('终止会话', 'var(--red,var(--red))') +
2071
- `<button style="display:block;width:100%;background:none;border:none;border-top:6px solid var(--bg,var(--bg));color:var(--muted,var(--muted));font-size:0.9rem;padding:0.85rem;cursor:pointer">取消</button>`;
2072
- overlay.appendChild(box);
2073
- const close = () => overlay.remove();
2074
- overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
2075
- const btns = box.querySelectorAll('button');
2076
- btns[0].onclick = () => { close(); openSession(id); };
2077
- btns[1].onclick = () => {
2078
- close();
2079
- const nv = prompt('重命名会话', title);
2080
- if (nv && nv.trim()) socket.emit('rename', { sessionId: id, title: nv.trim() });
2368
+ overlay.id = 'card-menu';
2369
+ overlay.className = 'sheet-mode';
2370
+ overlay.innerHTML = `<div class="cm-box"><div class="cm-title">${esc(title)}</div>` +
2371
+ items.map(([l, , danger], i) => `<button data-i="${i}" class="${danger ? 'danger' : ''}">${l}</button>`).join('') +
2372
+ `<button class="cm-cancel">取消</button></div>`;
2373
+ document.body.appendChild(overlay);
2374
+ overlay.addEventListener('click', (ev) => { if (ev.target === overlay) closeCardMenu(); });
2375
+ overlay.querySelector('.cm-cancel').onclick = closeCardMenu;
2376
+ overlay.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => { closeCardMenu(); items[b.dataset.i][1](); });
2377
+ }
2378
+ function closeCardMenu() {
2379
+ const m = document.getElementById('card-menu');
2380
+ if (m) { m._anchor?.classList.remove('on'); m.remove(); }
2381
+ }
2382
+ // 弹层贴着锚点(按钮或鼠标位置)放,超出视口就往回收
2383
+ function placePopover(el, anchor) {
2384
+ const r = anchor.getBoundingClientRect ? anchor.getBoundingClientRect() : { left: anchor.x, right: anchor.x, top: anchor.y, bottom: anchor.y };
2385
+ const w = el.offsetWidth, h = el.offsetHeight;
2386
+ let x = r.left, y = r.bottom + 4;
2387
+ if (x + w > innerWidth - 8) x = Math.max(8, r.right - w);
2388
+ if (y + h > innerHeight - 8) y = Math.max(8, r.top - h - 4);
2389
+ el.style.left = x + 'px'; el.style.top = y + 'px';
2390
+ }
2391
+ document.addEventListener('mousedown', (e) => {
2392
+ if (!e.target.closest('#card-menu') && !e.target.closest('.card-more')) closeCardMenu();
2393
+ if (!e.target.closest('#tag-pop') && !e.target.closest('#card-menu')) closeTagEditor();
2394
+ });
2395
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeCardMenu(); closeTagEditor(); } });
2396
+
2397
+ // 原地改名:标题变输入框,回车 / 失焦保存,Esc 取消
2398
+ function inlineRename(id) {
2399
+ const card = document.querySelector(`#list .card[data-id="${CSS.escape(id)}"]`);
2400
+ const t = card?.querySelector('.card-title');
2401
+ if (!t) return;
2402
+ const old = _allSessions.find(x => x.id === id)?.title || t.textContent;
2403
+ _renaming = id;
2404
+ const inp = document.createElement('input');
2405
+ inp.className = 'rename-inp';
2406
+ inp.value = old;
2407
+ inp.maxLength = 80;
2408
+ t.replaceWith(inp);
2409
+ inp.focus(); inp.select();
2410
+ let done = false;
2411
+ const finish = (save) => {
2412
+ if (done) return; done = true;
2413
+ const v = inp.value.trim();
2414
+ if (save && v && v !== old) {
2415
+ socket.emit('rename', { sessionId: id, title: v });
2416
+ const s = _allSessions.find(x => x.id === id); if (s) s.title = v;
2417
+ }
2418
+ _renaming = null;
2419
+ _renderPending = false;
2420
+ renderSessions();
2081
2421
  };
2082
- btns[2].onclick = () => {
2083
- close();
2084
- const nv = prompt('设置标签(多个用逗号分隔,留空清除)', curTags);
2085
- if (nv !== null) socket.emit('set-tags', { sessionId: id, tags: nv.split(/[,,]/).map(t => t.trim()).filter(Boolean) });
2422
+ inp.addEventListener('keydown', (ev) => {
2423
+ if (ev.key === 'Enter' && !ev.isComposing) { ev.preventDefault(); finish(true); }
2424
+ else if (ev.key === 'Escape') { ev.stopPropagation(); finish(false); }
2425
+ });
2426
+ inp.addEventListener('blur', () => finish(true));
2427
+ inp.addEventListener('click', (ev) => ev.stopPropagation());
2428
+ }
2429
+
2430
+ // 标签编辑:当前标签(点 × 去掉)+ 其他任务用过的标签(点一下加上)+ 输入新标签回车;每次改动立即保存
2431
+ function openTagEditor(id, anchor) {
2432
+ closeTagEditor();
2433
+ const s = _allSessions.find(x => x.id === id);
2434
+ if (!s) return;
2435
+ const pop = document.createElement('div');
2436
+ pop.id = 'tag-pop';
2437
+ if (!matchMedia('(pointer: fine)').matches) pop.classList.add('sheet-mode');
2438
+ document.body.appendChild(pop);
2439
+ const save = (tags) => {
2440
+ s.tags = tags;
2441
+ socket.emit('set-tags', { sessionId: id, tags });
2442
+ draw();
2086
2443
  };
2087
- btns[3].onclick = () => { close(); showKill(e, id, title); };
2088
- btns[4].onclick = close;
2089
- document.body.appendChild(overlay);
2444
+ function draw() {
2445
+ const cur = s.tags || [];
2446
+ const all = [...new Set(_allSessions.flatMap(x => x.tags || []))].filter(t => !cur.includes(t)).sort((a, b) => a.localeCompare(b, 'zh'));
2447
+ pop.innerHTML = `<div class="tp-title">🏷 「${esc(s.title || '')}」的标签</div>` +
2448
+ `<div class="tp-cur">${cur.length ? cur.map(t => `<span class="tp-chip">${esc(t)}<b data-del="${esc(t)}">×</b></span>`).join('') : '<span class="tp-none">还没有标签</span>'}</div>` +
2449
+ `<input class="tp-inp" placeholder="输入新标签,回车添加" maxlength="20">` +
2450
+ (all.length ? `<div class="tp-sub">点一下添加:</div><div class="tp-all">${all.map(t => `<span class="tp-sug" data-add="${esc(t)}">+ ${esc(t)}</span>`).join('')}</div>` : '') +
2451
+ `<button class="tp-done">完成</button>`;
2452
+ pop.querySelectorAll('[data-del]').forEach(b => b.onclick = () => save(cur.filter(t => t !== b.dataset.del)));
2453
+ pop.querySelectorAll('[data-add]').forEach(b => b.onclick = () => save([...cur, b.dataset.add]));
2454
+ const inp = pop.querySelector('.tp-inp');
2455
+ inp.addEventListener('keydown', (ev) => {
2456
+ if (ev.key === 'Enter' && !ev.isComposing) {
2457
+ ev.preventDefault();
2458
+ const v = inp.value.trim().replace(/[,,]/g, '');
2459
+ if (v && !cur.includes(v)) save([...cur, v]); else inp.value = '';
2460
+ }
2461
+ });
2462
+ pop.querySelector('.tp-done').onclick = closeTagEditor;
2463
+ if (!pop.classList.contains('sheet-mode')) placePopover(pop, anchor);
2464
+ setTimeout(() => pop.querySelector('.tp-inp')?.focus(), 0);
2465
+ }
2466
+ draw();
2090
2467
  }
2468
+ function closeTagEditor() { document.getElementById('tag-pop')?.remove(); }
2091
2469
 
2092
2470
  function showKill(e, id, title) {
2093
2471
  if (e && e.preventDefault) e.preventDefault();