@liguoshuai/pi-web-chat 1.0.1 → 1.1.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.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
- ## [Unreleased]
10
+ ## [1.1.0] - 2026-07-26
11
+
12
+ ### Added
13
+ - **后台进程持久化 (Process Persistence)**:刷新或暂时离开 Web 页面时,`pi` Agent 进程继续在后台运行,重新打开/刷新网页会自动重挂载 (re-attach) 正在运行的进程,任务不会中断。
14
+ - **历史工具输出归折 (Clean Tool Results Rendering)**:从历史记录恢复会话时,将离散的 `toolResult` 输出与对应 `toolCall` 绑定,整洁收纳于工具调用的 `⚙` 折叠卡片内,避免原始日志/代码平铺乱穿于聊天框中。
15
+
16
+ ### Fixed
17
+ - **历史会话加载异常**:修复 `/api/session` 接口中局部变量名遮蔽 Node.js `path` 模块导致的无法弹出会话历史问题。
18
+
19
+ ---
20
+
21
+ ## [1.0.1] - 2026-07-20
11
22
 
12
23
  ### Fixed
13
24
  - **空内容 pi 气泡**:`message_start` 原本不区分角色,用户消息回显也建了一个空 pi 气泡 — 现在只对 `role:assistant` 开 streaming 块。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/public/app.js CHANGED
@@ -24,13 +24,16 @@ const state = {
24
24
  ws: null,
25
25
  wsConnected: false,
26
26
  cwd: null,
27
+ homeDir: "",
28
+ serverCwd: "",
27
29
  currentSessionFile: null,
28
30
  // entriesByCallId: for live assistant messages we accumulate tool calls + text
29
31
  streamingMsg: null, // DOM node for the in-progress assistant message
30
32
  streamingText: "", // accumulated text deltas
31
33
  streamingThinking: "",
34
+ thinkingOpen: true,
35
+ thinkingUserToggled: false,
32
36
  activeToolCalls: new Map(), // toolCallId -> { node, body, state }
33
- thinkingOpen: false,
34
37
  queuedAssistantTextId: null,
35
38
  streaming: false,
36
39
  models: [],
@@ -39,6 +42,134 @@ const state = {
39
42
  sessionId: null,
40
43
  };
41
44
 
45
+ let toastTimer = null;
46
+ function showToast(msg) {
47
+ const toast = $("#toast");
48
+ if (!toast) return;
49
+ toast.textContent = msg;
50
+ toast.classList.add("show");
51
+ if (toastTimer) clearTimeout(toastTimer);
52
+ toastTimer = setTimeout(() => {
53
+ toast.classList.remove("show");
54
+ }, 2500);
55
+ }
56
+
57
+ function formatCwdDisplay(p) {
58
+ if (!p) return "~";
59
+ const home = state.homeDir;
60
+ if (home && (p === home || p === home + "/")) return "~";
61
+ if (home && p.startsWith(home + "/")) return "~/" + p.slice(home.length + 1);
62
+ return p;
63
+ }
64
+
65
+ function updateCwdDisplay() {
66
+ const pill = $("#cwdPill");
67
+ const wrap = $("#cwdPillWrap");
68
+ if (pill) pill.textContent = formatCwdDisplay(state.cwd);
69
+ if (wrap) wrap.title = `工作目录: ${state.cwd || "~"}`;
70
+ }
71
+
72
+ function setCwd(newCwd) {
73
+ state.cwd = newCwd;
74
+ localStorage.setItem("pi_cwd", newCwd);
75
+ updateCwdDisplay();
76
+ clearChat();
77
+ showEmptyState(true);
78
+ state.currentSessionFile = null;
79
+ $("#topSessionName").textContent = "新对话";
80
+ connectWs({});
81
+ refreshSessions();
82
+ }
83
+
84
+ async function loadServerConfig() {
85
+ try {
86
+ const res = await fetch(`${API}/api/config`);
87
+ const data = await res.json();
88
+ if (data.home) state.homeDir = data.home;
89
+ if (data.serverCwd) state.serverCwd = data.serverCwd;
90
+ } catch {}
91
+ if (!state.cwd) {
92
+ state.cwd = localStorage.getItem("pi_cwd") || state.serverCwd || state.homeDir || "";
93
+ }
94
+ updateCwdDisplay();
95
+ }
96
+
97
+ async function openCwdModal() {
98
+ await loadServerConfig();
99
+ const modal = $("#cwdModal");
100
+ const input = $("#cwdInput");
101
+ const errorEl = $("#cwdError");
102
+ const chipsEl = $("#quickDirChips");
103
+ if (!modal || !input) return;
104
+
105
+ input.value = state.cwd || state.homeDir || "";
106
+ if (errorEl) { errorEl.style.display = "none"; errorEl.textContent = ""; }
107
+
108
+ if (chipsEl) {
109
+ chipsEl.innerHTML = "";
110
+ const quicks = [];
111
+ if (state.homeDir) quicks.push({ label: "~ (用户主页)", path: state.homeDir });
112
+ if (state.serverCwd && state.serverCwd !== state.homeDir) {
113
+ quicks.push({ label: "服务启动目录", path: state.serverCwd });
114
+ }
115
+ let recents = [];
116
+ try { recents = JSON.parse(localStorage.getItem("pi_recent_cwds") || "[]"); } catch {}
117
+ recents.forEach(r => {
118
+ if (r && r !== state.homeDir && r !== state.serverCwd && !quicks.some(q => q.path === r)) {
119
+ quicks.push({ label: formatCwdDisplay(r), path: r });
120
+ }
121
+ });
122
+
123
+ quicks.forEach(q => {
124
+ chipsEl.appendChild(el("div", {
125
+ class: "chip",
126
+ text: q.label,
127
+ onclick: () => { input.value = q.path; }
128
+ }));
129
+ });
130
+ }
131
+
132
+ modal.classList.add("open");
133
+ setTimeout(() => input.select(), 50);
134
+ }
135
+
136
+ function closeCwdModal() {
137
+ const modal = $("#cwdModal");
138
+ if (modal) modal.classList.remove("open");
139
+ }
140
+
141
+ async function confirmCwdChange() {
142
+ const input = $("#cwdInput");
143
+ const errorEl = $("#cwdError");
144
+ const rawPath = input.value.trim();
145
+ if (!rawPath) return;
146
+
147
+ try {
148
+ const res = await fetch(`${API}/api/validate-dir?path=${encodeURIComponent(rawPath)}`);
149
+ const data = await res.json();
150
+ if (data.ok && data.path) {
151
+ let recents = [];
152
+ try { recents = JSON.parse(localStorage.getItem("pi_recent_cwds") || "[]"); } catch {}
153
+ recents = [data.path, ...recents.filter(r => r !== data.path)].slice(0, 8);
154
+ localStorage.setItem("pi_recent_cwds", JSON.stringify(recents));
155
+
156
+ closeCwdModal();
157
+ setCwd(data.path);
158
+ showToast(`已切换工作目录: ${formatCwdDisplay(data.path)}`);
159
+ } else {
160
+ if (errorEl) {
161
+ errorEl.textContent = data.error || "指定路径无法访问";
162
+ errorEl.style.display = "block";
163
+ }
164
+ }
165
+ } catch (e) {
166
+ if (errorEl) {
167
+ errorEl.textContent = "网络请求失败,请重试";
168
+ errorEl.style.display = "block";
169
+ }
170
+ }
171
+ }
172
+
42
173
  // ---- Markdown render (small, safe renderer) ----
43
174
  function escapeHtml(s) {
44
175
  return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
@@ -249,8 +380,24 @@ function renderSidebar(sessions) {
249
380
  });
250
381
  }
251
382
 
383
+ async function toggleSidebar() {
384
+ const app = $(".app");
385
+ const isOpen = app.classList.toggle("sidebar-open");
386
+ if (window.innerWidth > 768) {
387
+ localStorage.setItem("sidebarCollapsed", !isOpen);
388
+ }
389
+ }
390
+
391
+ function closeSidebar() {
392
+ $(".app").classList.remove("sidebar-open");
393
+ }
394
+
252
395
  async function loadSession(file) {
253
396
  state.currentSessionFile = file;
397
+ // Mobile: close sidebar on selection
398
+ if (window.innerWidth <= 768) {
399
+ closeSidebar();
400
+ }
254
401
  // pull transcript from REST then connect a fresh WS pointed at this session
255
402
  const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
256
403
  const data = await res.json();
@@ -270,7 +417,17 @@ async function loadSession(file) {
270
417
  }
271
418
 
272
419
  function reconstructFromEntries(entries) {
273
- // entries contains message + message_summary + model_change etc., in path order.
420
+ // Map toolResults by toolCallId so we can attach them to their toolCall in the assistant message
421
+ const toolResults = new Map();
422
+ for (const e of entries) {
423
+ if (e.type === "message" && e.message?.role === "toolResult") {
424
+ const m = e.message;
425
+ if (m.toolCallId) {
426
+ toolResults.set(m.toolCallId, m);
427
+ }
428
+ }
429
+ }
430
+
274
431
  const out = [];
275
432
  for (const e of entries) {
276
433
  if (e.type !== "message") continue;
@@ -280,10 +437,17 @@ function reconstructFromEntries(entries) {
280
437
  // skip "bash execution" pseudo-users (those have role user but content type special)
281
438
  out.push({ role: "user", text: extractContentText(m.content), ts: m.timestamp });
282
439
  } else if (m.role === "assistant") {
283
- out.push({ role: "assistant", content: m.content, ts: m.timestamp, usage: m.usage });
284
- } else if (m.role === "toolResult") {
285
- out.push({ role: "toolResult", toolCallId: m.toolCallId, toolName: m.toolName, content: m.content, isError: m.isError, ts: m.timestamp });
440
+ const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
441
+ const content = rawContent.map(part => {
442
+ if (part && part.type === "toolCall") {
443
+ const res = toolResults.get(part.id);
444
+ return { ...part, result: res || null };
445
+ }
446
+ return part;
447
+ });
448
+ out.push({ role: "assistant", content, ts: m.timestamp, usage: m.usage });
286
449
  }
450
+ // toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
287
451
  }
288
452
  return out;
289
453
  }
@@ -304,6 +468,8 @@ function clearChat() {
304
468
  state.streamingMsg = null;
305
469
  state.streamingText = "";
306
470
  state.streamingThinking = "";
471
+ state.thinkingOpen = true;
472
+ state.thinkingUserToggled = false;
307
473
  state.activeToolCalls.clear();
308
474
  }
309
475
 
@@ -349,14 +515,30 @@ function renderAssistantBlock(m) {
349
515
  return node;
350
516
  }
351
517
 
352
- function makeThinkingBlock(thinkingText) {
353
- const block = el("div", { class: "thinking-block" });
354
- const head = el("div", { class: "thinking-head", onclick: () => body.style.display = body.style.display === "none" ? "block" : "none" }, [
355
- el("span", { text: "💭 思考过程" }),
356
- el("span", { text: "(点击展开/收起)" }),
518
+ function makeThinkingBlock(thinkingText, isActivelyThinking = false) {
519
+ const block = el("div", { class: "thinking-block" + (isActivelyThinking ? " active" : "") });
520
+ const head = el("div", {
521
+ class: "thinking-head",
522
+ onclick: () => {
523
+ const isHidden = body.style.display === "none";
524
+ body.style.display = isHidden ? "block" : "none";
525
+ state.thinkingOpen = isHidden;
526
+ state.thinkingUserToggled = true;
527
+ }
528
+ }, [
529
+ el("span", { class: "thinking-title", text: isActivelyThinking ? "💭 正在思考中…" : "💭 思考过程" }),
530
+ isActivelyThinking ? el("span", { class: "thinking-pulse" }) : null,
531
+ el("span", { class: "thinking-toggle-hint", text: "(点击展开/收起)" }),
357
532
  ]);
533
+
358
534
  const body = el("div", { class: "thinking-body", html: escapeHtml(thinkingText) });
359
- body.style.display = "none";
535
+
536
+ if (state.thinkingUserToggled) {
537
+ body.style.display = state.thinkingOpen ? "block" : "none";
538
+ } else {
539
+ body.style.display = isActivelyThinking ? "block" : "none";
540
+ }
541
+
360
542
  block.appendChild(head);
361
543
  block.appendChild(body);
362
544
  return block;
@@ -364,21 +546,42 @@ function makeThinkingBlock(thinkingText) {
364
546
 
365
547
  function makeToolBlockFromCall(call) {
366
548
  const block = el("div", { class: "tool-block" });
549
+ const hasResult = Boolean(call.result);
550
+ const isError = call.result ? Boolean(call.result.isError) : false;
551
+
552
+ let resultText = "";
553
+ if (hasResult) {
554
+ resultText = extractContentText(call.result.content);
555
+ }
556
+
557
+ const stateText = hasResult ? (isError ? "错误" : "完成") : "…";
558
+ const stateClass = "state" + (hasResult && isError ? " error" : "");
559
+
367
560
  const head = el("div", { class: "tool-head" }, [
368
561
  el("span", { class: "ic", text: "⚙" }),
369
562
  el("span", { class: "name", text: call.name }),
370
563
  el("span", { class: "args", text: summaryArgs(call.name, call.arguments) }),
371
- el("span", { class: "state", text: "…" }),
564
+ el("span", { class: stateClass, text: stateText }),
372
565
  ]);
373
- const body = el("div", { class: "tool-body", html: "执行中…" });
566
+
567
+ const bodyText = hasResult ? (resultText || "(无输出)") : "执行中…";
568
+ const body = el("div", { class: "tool-body", html: escapeHtml(bodyText) });
374
569
  body.style.display = "none";
375
- head.addEventListener("click", () => body.style.display = body.style.display === "none" ? "block" : "none");
570
+
571
+ head.addEventListener("click", () => {
572
+ body.style.display = body.style.display === "none" ? "block" : "none";
573
+ });
574
+
376
575
  block.appendChild(head);
377
576
  block.appendChild(body);
378
577
  block._head = head;
379
578
  block._body = body;
380
579
  block._callId = call.id;
381
- state.activeToolCalls.set(call.id, { block, body, head });
580
+
581
+ if (!hasResult && call.id) {
582
+ state.activeToolCalls.set(call.id, { block, body, head });
583
+ }
584
+
382
585
  return block;
383
586
  }
384
587
 
@@ -412,6 +615,8 @@ function ensureStreamingMsg() {
412
615
  state.streamingMsg = node;
413
616
  state.streamingText = "";
414
617
  state.streamingThinking = "";
618
+ state.thinkingOpen = true;
619
+ state.thinkingUserToggled = false;
415
620
  state.activeToolCalls.clear();
416
621
  $("#chat-inner").appendChild(node);
417
622
  scrollBottom();
@@ -422,28 +627,45 @@ function refreshStreamingContent() {
422
627
  const node = state.streamingMsg;
423
628
  if (!node) return;
424
629
  const content = node.querySelector(".content");
425
- // Build the current content html again from scratch.
426
- // Order: text then thinking then tool calls. We keep it simple — append in
427
- // arrival order using permanent child slots keyed by index.
428
- // Easiest: rebuild.
429
630
  content.innerHTML = "";
430
- if (state.streamingThinking) {
431
- content.appendChild(makeThinkingBlock(state.streamingThinking));
631
+
632
+ const hasThinking = Boolean(state.streamingThinking);
633
+ const hasText = Boolean(state.streamingText);
634
+ const hasTools = state.activeToolCalls.size > 0;
635
+
636
+ // Immediate visual feedback placeholder while waiting for LLM first token
637
+ if (state.streaming && !hasThinking && !hasText && !hasTools) {
638
+ content.appendChild(el("div", { class: "thinking-placeholder" }, [
639
+ el("span", { class: "thinking-spinner" }),
640
+ el("span", { class: "thinking-label", text: "正在思考中…" })
641
+ ]));
642
+ scrollBottom();
643
+ return;
432
644
  }
433
- if (state.streamingText) {
434
- content.appendChild(el("div", { html: renderMarkdown(state.streamingText) + (state.streaming ? '<span class="typing-cursor"></span>' : "") }));
645
+
646
+ if (state.streamingThinking) {
647
+ const isActivelyThinking = state.streaming && !hasText && !hasTools;
648
+ content.appendChild(makeThinkingBlock(state.streamingThinking, isActivelyThinking));
435
649
  }
436
- // Re-append tool call blocks. Active ones are kept in a Map by insertion order.
437
650
  for (const v of state.activeToolCalls.values()) {
438
651
  content.appendChild(v.block);
439
652
  }
653
+ if (state.streamingText) {
654
+ content.appendChild(el("div", { html: renderMarkdown(state.streamingText) + (state.streaming ? '<span class="typing-cursor"></span>' : "") }));
655
+ }
440
656
  scrollBottom();
441
657
  }
442
658
 
443
659
  function finalizeStreamingMsg() {
660
+ state.streaming = false;
661
+ if (state.streamingMsg) {
662
+ refreshStreamingContent();
663
+ }
444
664
  state.streamingMsg = null;
445
665
  state.streamingText = "";
446
666
  state.streamingThinking = "";
667
+ state.thinkingOpen = true;
668
+ state.thinkingUserToggled = false;
447
669
  state.activeToolCalls.clear();
448
670
  }
449
671
 
@@ -511,6 +733,18 @@ function handlePiMessage(obj) {
511
733
  if (obj.type === "response") {
512
734
  if (obj.command === "get_state" && obj.success) updateState(obj.data);
513
735
  else if (obj.command === "get_available_models" && obj.success) updateModels(obj.data.models || []);
736
+ else if (obj.command === "set_model") {
737
+ if (obj.success) {
738
+ if (obj.data) state.currentModel = obj.data;
739
+ renderModelPill();
740
+ renderModelMenu();
741
+ showToast(`已切换模型: ${state.currentModel?.name || state.currentModel?.id || ""}`);
742
+ sendWs({ type: "get_state" });
743
+ } else {
744
+ showToast(`切换模型失败: ${obj.error || "未知错误"}`);
745
+ renderModelPill();
746
+ }
747
+ }
514
748
  else if (obj.command === "switch_session" && obj.success) {
515
749
  // ask pi for current state so we can get session id, name
516
750
  sendWs({ type: "get_state" });
@@ -533,17 +767,15 @@ function handlePiMessage(obj) {
533
767
  case "agent_start":
534
768
  state.streaming = true;
535
769
  setComposerAborting(true);
536
- // Refresh opportunistically; pi's session file may not be fsync'd yet
537
- // at agent_start (in which case this refresh is a no-op), but the
538
- // later agent_settled branch refreshes too — so a new conversation
539
- // shows up in the sidebar as soon as the reply finishes, without
540
- // needing a manual page reload.
770
+ ensureStreamingMsg();
771
+ refreshStreamingContent();
541
772
  refreshSessions();
542
773
  break;
543
774
  case "agent_end":
544
775
  finalizeStreamingMsg();
545
776
  break;
546
777
  case "agent_settled":
778
+ finalizeStreamingMsg();
547
779
  state.streaming = false;
548
780
  setComposerAborting(false);
549
781
  refreshSessions(); // titles may have changed
@@ -657,6 +889,7 @@ function handlePiMessage(obj) {
657
889
  break;
658
890
  }
659
891
  case "pi_exit":
892
+ finalizeStreamingMsg();
660
893
  state.streaming = false;
661
894
  setComposerAborting(false);
662
895
  $("#connDot").style.color = "var(--danger)";
@@ -698,7 +931,9 @@ function renderModelPill() {
698
931
  const pill = $("#modelPill");
699
932
  if (!m) { pill.textContent = "选择模型"; return; }
700
933
  const provider = m.provider || "?";
701
- pill.textContent = `${provider} / ${m.id || m.name}`;
934
+ const name = m.name || m.id;
935
+ pill.textContent = `${provider} / ${name}`;
936
+ pill.title = `当前模型: ${provider} / ${name} (${m.id})`;
702
937
  }
703
938
 
704
939
  function renderModelMenu() {
@@ -716,7 +951,11 @@ function renderModelMenu() {
716
951
  const active = state.currentModel && m.id === state.currentModel.id && m.provider === state.currentModel.provider;
717
952
  menu.appendChild(el("div", {
718
953
  class: "opt" + (active ? " active" : ""),
719
- onclick: () => { sendWs({ type: "set_model", provider: m.provider, modelId: m.id }); menu.classList.remove("open"); },
954
+ onclick: () => {
955
+ $("#modelPill").textContent = "切换中…";
956
+ sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
957
+ menu.classList.remove("open");
958
+ },
720
959
  }, [
721
960
  el("span", { class: "check", html: active ? "✓ " : "" }),
722
961
  document.createTextNode(`${m.name || m.id}`),
@@ -766,6 +1005,12 @@ function submitPrompt() {
766
1005
  appendMessageNode("user", { text });
767
1006
  ta.value = "";
768
1007
  autoResize();
1008
+
1009
+ state.streaming = true;
1010
+ setComposerAborting(true);
1011
+ ensureStreamingMsg();
1012
+ refreshStreamingContent();
1013
+
769
1014
  // Set session name from the first prompt of a brand-new session.
770
1015
  if (state.currentSessionFile == null) {
771
1016
  sendWs({ type: "set_session_name", name: text.slice(0, 60).replace(/\s+/g, " ") });
@@ -784,6 +1029,7 @@ function init() {
784
1029
  // Default cwd to home (server uses home default too).
785
1030
  state.cwd = document.body.dataset.cwd || "";
786
1031
 
1032
+ // event listeners
787
1033
  // event listeners
788
1034
  $("#btnNew").addEventListener("click", () => {
789
1035
  if (state.streaming) {
@@ -795,8 +1041,8 @@ function init() {
795
1041
  connectWs({}); // no session -> pi creates a new one
796
1042
  $("#topSessionName").textContent = "新对话";
797
1043
  state.currentSessionFile = null;
798
- // Pull the updated sidebar immediately so the just-opened session
799
- // appears as soon as pi reports back (and on subsequent resolves).
1044
+ // Mobile: close sidebar on new session
1045
+ if (window.innerWidth <= 768) closeSidebar();
800
1046
  refreshSessions();
801
1047
  });
802
1048
 
@@ -831,6 +1077,9 @@ function init() {
831
1077
  });
832
1078
  });
833
1079
 
1080
+ $("#btnToggleSidebar").addEventListener("click", toggleSidebar);
1081
+ $("#sidebarOverlay").addEventListener("click", closeSidebar);
1082
+
834
1083
  // sidebar search (client side filter)
835
1084
  $("#sidebarSearch").addEventListener("input", (e) => {
836
1085
  const q = e.target.value.toLowerCase();
@@ -839,6 +1088,64 @@ function init() {
839
1088
  });
840
1089
  });
841
1090
 
1091
+ // Restore sidebar state for desktop, collapse by default on mobile
1092
+ if (window.innerWidth > 768) {
1093
+ if (localStorage.getItem("sidebarCollapsed") === "true") {
1094
+ $(".app").classList.remove("sidebar-open");
1095
+ }
1096
+ } else {
1097
+ $(".app").classList.remove("sidebar-open");
1098
+ }
1099
+
1100
+ // Warn user if navigating/refreshing while a response is streaming
1101
+ window.addEventListener("beforeunload", (e) => {
1102
+ if (state.streaming) {
1103
+ e.preventDefault();
1104
+ e.returnValue = "任务正在运行中,刷新或离开页面将中断当前任务。";
1105
+ return e.returnValue;
1106
+ }
1107
+ });
1108
+
1109
+ // Keep sidebar state consistent across viewport resizes.
1110
+ // Desktop uses an inline sidebar; mobile uses a drawer with an overlay.
1111
+ // If the user resizes across the 768px breakpoint, a stale `sidebar-open`
1112
+ // class would either show a stray inline sidebar on a collapsed desktop
1113
+ // layout, or worse, leave the full-screen overlay covering the main area
1114
+ // on mobile — making the whole UI unclickable. Sync on every resize.
1115
+ let lastIsMobile = window.innerWidth <= 768;
1116
+ window.addEventListener("resize", () => {
1117
+ const isMobile = window.innerWidth <= 768;
1118
+ if (isMobile !== lastIsMobile) {
1119
+ lastIsMobile = isMobile;
1120
+ // Crossing the breakpoint: always close the drawer to reach a known state.
1121
+ $(".app").classList.remove("sidebar-open");
1122
+ }
1123
+ });
1124
+
1125
+ // CWD modal listeners
1126
+ const cwdBtn = $("#cwdPillWrap");
1127
+ if (cwdBtn) cwdBtn.addEventListener("click", openCwdModal);
1128
+ const cwdCloseBtn = $("#cwdModalClose");
1129
+ if (cwdCloseBtn) cwdCloseBtn.addEventListener("click", closeCwdModal);
1130
+ const cwdConfirmBtn = $("#cwdConfirmBtn");
1131
+ if (cwdConfirmBtn) cwdConfirmBtn.addEventListener("click", confirmCwdChange);
1132
+ const cwdModalEl = $("#cwdModal");
1133
+ if (cwdModalEl) {
1134
+ cwdModalEl.addEventListener("click", (e) => {
1135
+ if (e.target === cwdModalEl) closeCwdModal();
1136
+ });
1137
+ }
1138
+ const cwdInputEl = $("#cwdInput");
1139
+ if (cwdInputEl) {
1140
+ cwdInputEl.addEventListener("keydown", (e) => {
1141
+ if (e.key === "Enter") { e.preventDefault(); confirmCwdChange(); }
1142
+ else if (e.key === "Escape") { e.preventDefault(); closeCwdModal(); }
1143
+ });
1144
+ }
1145
+
1146
+ // Load server config & restore saved CWD
1147
+ loadServerConfig();
1148
+
842
1149
  refreshSessions();
843
1150
  // start in the disconnected state; connectWs will flip to green on open.
844
1151
  const initDot = $("#connDot");
package/public/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ctext y='20' font-size='20'%3Eπ%3C/text%3E%3C/svg%3E">
9
9
  </head>
10
10
  <body data-cwd="">
11
- <div class="app">
11
+ <div class="app sidebar-open">
12
12
  <!-- Sidebar -->
13
13
  <aside class="sidebar">
14
14
  <div class="sidebar-top">
@@ -27,11 +27,22 @@
27
27
  </div>
28
28
  </aside>
29
29
 
30
+ <!-- Sidebar Overlay -->
31
+ <div class="sidebar-overlay" id="sidebarOverlay"></div>
32
+
30
33
  <!-- Main -->
31
34
  <main class="main">
32
35
  <div class="topbar">
36
+ <button class="btn-toggle-sidebar" id="btnToggleSidebar" title="切换侧边栏">
37
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
38
+ <line x1="3" y1="12" x2="21" y2="12"></line>
39
+ <line x1="3" y1="6" x2="21" y2="6"></line>
40
+ <line x1="3" y1="18" x2="21" y2="18"></line>
41
+ </svg>
42
+ </button>
33
43
  <div class="session-name" id="topSessionName">新对话</div>
34
- <button class="btn-ghost" id="modelPillWrap" title="工作目录">
44
+ <button class="btn-ghost" id="cwdPillWrap" title="点击切换工作目录">
45
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; vertical-align:-2px;"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
35
46
  <span id="cwdPill">~</span>
36
47
  </button>
37
48
  <div style="position:relative;">
@@ -63,6 +74,31 @@
63
74
  </div>
64
75
  </main>
65
76
  </div>
77
+
78
+ <!-- CWD Modal -->
79
+ <div class="modal-overlay" id="cwdModal">
80
+ <div class="modal">
81
+ <div class="modal-header">
82
+ <h3>切换工作目录</h3>
83
+ <button class="btn-close" id="cwdModalClose">&times;</button>
84
+ </div>
85
+ <div class="modal-body">
86
+ <p class="modal-desc">选择或输入 pi 代理运行的目标工作目录:</p>
87
+ <div class="input-group">
88
+ <input type="text" id="cwdInput" placeholder="/path/to/project" />
89
+ <button class="btn-primary" id="cwdConfirmBtn">确定切换</button>
90
+ </div>
91
+ <div id="cwdError" class="modal-error"></div>
92
+ <div class="quick-dirs">
93
+ <span class="quick-label">快捷选择:</span>
94
+ <div id="quickDirChips" class="chips"></div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Toast notification -->
101
+ <div class="toast" id="toast"></div>
66
102
  <script src="/app.js"></script>
67
103
  </body>
68
104
  </html>