@liguoshuai/pi-web-chat 1.8.0 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # 架构设计文档
2
2
 
3
- > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.7.5 版本)。
3
+ > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.8.3 版本)。
4
4
 
5
5
  ---
6
6
 
package/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.8.3] - 2026-07-26
11
+
12
+ ### Fixed
13
+ - **移动端工作目录切换弹窗按钮溢出修复 (CWD Modal Mobile Overflow & Input Sizing)**:
14
+ - 修复手机端切换工作目录弹窗中“确定切换”按钮超出卡片右侧边框的问题。
15
+ - 为 `.input-group input` 添加 `min-width: 0` 和 `box-sizing: border-box`,消除原生 input 元素的内在最小宽度导致的 Flexbox 溢出。
16
+ - 适配移动端弹窗内边距与间距(padding 降至 16px),并在 `<= 380px` 窄屏设备上自适应将按钮折行全宽呈现,防止水平溢出。
17
+
18
+ ---
19
+
20
+ ## [1.8.2] - 2026-07-26
21
+
22
+ ### Fixed
23
+ - **移动端顶栏空间挤压与思考胶囊截断修复 (Mobile Topbar Layout & Thinking Pill Overflow)**:
24
+ - 移除移动端 `.model-pill-container` 的固定 `max-width: 130px` 宽度限制,修复深度思考胶囊被挤压截断、只显示一半图标的问题。
25
+ - 优化移动端顶栏各元素间距与自适应弹性缩放:侧边栏菜单按钮调整为 36px,工作目录胶囊最大宽度限制为 75px,模型胶囊适度收敛文字并隐藏默认徽章,思考胶囊保留完整图标与级别文本。
26
+ - 增加对 `<= 360px` 极窄小屏设备的精细化响应式适配规则,保障全尺寸移动端设备顶部操作栏整齐美观且不换行、不溢出。
27
+
28
+ ---
29
+
30
+ ## [1.8.1] - 2026-07-26
31
+
32
+ ### Fixed
33
+ - **模型切换系统通知未定义函数报错修复 (Missing appendSystemNotice ReferenceError)**:补充前端 `appendSystemNotice` 函数实现,使用 `.system-notice-divider` 与 `.system-notice-text` 渲染模型变更系统提示,解决切换模型时触发 `ReferenceError` 的问题。
34
+ - **思考深度循环切换响应数据字段适配 (cycle_thinking_level Response Handling)**:兼容 Pi RPC 响应中返回的 `data.level` 字段,避免因字段名不匹配导致思考深度未能及时更新。
35
+ - **会话自定义命名解析与侧边栏即时呈现 (Session Name & session_info Extraction)**:服务端 `/api/sessions` 与 `/api/session` 增加对 `session_info` 记录的解析,支持读取用户通过 `set_session_name` 设置的会话名称并在侧边栏和顶栏直观显示。
36
+ - **默认模型设为已启用模型列表防回退保护 (enabledModels Sync on Set Default)**:在写入 `defaultModel` 时同步检查并加入 `enabledModels` 列表,防止 Pi core 启动时回退至 `enabledModels[0]`。
37
+ - **会话历史加载异常容错与提示 (Session Load Error Handling)**:`loadSession` 增加网络及文件读取异常判断与 Toast 友好提示,避免读取异常会话文件时界面卡死。
38
+ - **Systemd User 单元文件权限沙箱配置优化 (Systemd Unit Workspace Access)**:移除 `ProtectHome=read-only`,确保作为用户级服务启动时 Pi Agent 可以正常读写和编辑工作区项目文件。
39
+
40
+ ---
41
+
10
42
  ## [1.8.0] - 2026-07-26
11
43
 
12
44
  ### Added
package/docs/ISSUES.md CHANGED
@@ -146,5 +146,71 @@
146
146
  - 修改 `detachWs`,当所有连接断开但 Agent 仍在生成中时,保留 `eventBuffer` 保证继续录制。
147
147
  - 修改 `replayBuffered`,在 Agent 仍处于 busy 状态时消费后不清空缓存,使多个设备/多个 Tab 可多次或同时连入并安全回溯追平全部生成细节,并在流式终止时通过 `agent_settled` 进行最终的统一清理。
148
148
 
149
+ ---
150
+
151
+ ## 14. 切换模型时触发 `appendSystemNotice is not defined` 报错
152
+
153
+ **症状**:在顶部模型下拉菜单中切换模型后,控制台报错 `ReferenceError: appendSystemNotice is not defined`,并在 `/api/log-error` 中记录错误日志。
154
+
155
+ **根因**:前端 `app.js` 在处理模型切换和接收 `set_model` 响应时调用了 `appendSystemNotice` 函数在消息流中插入切换提醒,但在重构时该辅助函数未被声明。
156
+
157
+ **修复**:在 `app.js` 中补充 `appendSystemNotice(text)` 实现,利用 CSS 中已定义好的 `.system-notice-divider` 与 `.system-notice-text` 类在聊天区尾部安全插入居中分割标签。
158
+
159
+ ---
160
+
161
+ ## 15. `cycle_thinking_level` 响应字段未正确更新思考深度
162
+
163
+ **症状**:通过 RPC 调用循环切换思考级别后,前端状态与顶栏思考胶囊未实时更新。
164
+
165
+ **根因**:Pi RPC 模式下 `cycle_thinking_level` 返回的响应数据为 `{ level: ThinkingLevel }`,而前端代码按 `obj.data?.thinkingLevel` 读取导致取得 `undefined`。
166
+
167
+ **修复**:在 `app.js` 中改为 `const newLevel = obj.data?.level || obj.data?.thinkingLevel;`,兼容标准 RPC 返回格式。
168
+
169
+ ---
170
+
171
+ ## 16. 自定义会话名称 (`session_info`) 无法在侧边栏与顶栏显示
172
+
173
+ **症状**:通过 `set_session_name` 重命名会话后,侧边栏列表中依然只展示第一条用户 prompt 提取出的简略标题。
174
+
175
+ **根因**:服务端 `getSessionMetadata` 和 `/api/session` 在逐行扫描 `.jsonl` 时只查找了 `type === "session"` 和 `type === "message"`,忽略了 `type === "session_info"` 记录。
176
+
177
+ **修复**:
178
+ - 服务端解析 `.jsonl` 时提取最新的 `session_info.name` 作为 `sessionName` 返回。
179
+ - 前端 `renderSidebar` 优先使用 `s.sessionName || s.firstUser || "新对话"` 进行渲染。
180
+
181
+ ---
182
+
183
+ ## 17. Systemd 用户服务中 `ProtectHome=read-only` 导致 Agent 读写工作区报错
184
+
185
+ **症状**:通过 `install-service.sh` 将服务安装至 systemd user 后,Pi 编程代理在用户项目目录中执行 `write` 或 `edit` 工具时触发 `EACCES` 权限拒绝错误。
186
+
187
+ **根因**:服务单元模板设置了 `ProtectHome=read-only` 并仅将 `~/.pi` 和 `~/.npm-global` 列入白名单,导致 Agent 无法在 `$HOME/projects` 等工作区目录下创建或修改文件。
188
+
189
+ **修复**:移除该限制,让 User-level 服务在常规用户权限上下文下直接操作工作区。
190
+
191
+ ---
192
+
193
+ ## 19. 移动端切换工作目录弹窗按钮超出边框
194
+
195
+ **症状**:在手机端点击顶栏切换工作目录(CWD Modal)时,右侧的“确定切换”按钮超出了弹窗白色/暗色卡片右边框。
196
+
197
+ **根因**:
198
+ 1. 原生 `<input>` 元素具有默认的内在 `size`(`min-width: auto` 相当于约 180px~200px)。在小屏手机上,input 的内在最小宽度加上“确定切换”按钮宽度和弹窗内边距超出了视口总可用宽度。
199
+ 2. 移动端没有针对 `.modal` 和 `.input-group` 进行专门的响应式约束和内边距收敛。
200
+
201
+ **修复**:
202
+ - 给 `.input-group input` 添加 `min-width: 0; box-sizing: border-box;`,确保其在 flex 容器中可以自由收缩。
203
+ - 在移动端媒体查询中调整弹窗内边距为 16px,优化输入框与按钮的 padding。
204
+ - 在 `<= 380px` 超窄屏设备上自适应将输入框与操作按钮上下垂直排列(`flex-direction: column`),确保彻底杜绝横向溢出。
205
+
206
+ **症状**:在手机端竖屏打开页面时,顶栏右侧的“深度思考 / 推理级别”(`🧠 High`)胶囊被挤压,只能看到一半的脑花图标,文本完全不可见。
207
+
208
+ **根因**:移动端媒体查询中为 `.model-pill-container` 设置了 `max-width: 130px` 的硬限制。由于模型胶囊占据了大部分宽度,容器剩余宽度不足以完整容纳思考胶囊,导致其被裁剪。
209
+
210
+ **修复**:
211
+ - 移除移动端 `.model-pill-container` 的 `max-width: 130px` 限制,改为弹性无约束容器。
212
+ - 优化顶栏各按钮尺寸与间距:缩小菜单按钮为 36px,工作目录胶囊限制最大 75px,模型胶囊适度缩略模型名并隐藏手机端冗余的“默认”徽章,为思考胶囊留出充足显示空间。
213
+ - 追加 `<= 360px` 极窄屏响应式规则,确保在 iPhone SE 等小屏设备上也能完整显示。
214
+
149
215
 
150
216
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.8.0",
3
+ "version": "1.8.3",
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
@@ -465,7 +465,7 @@ function renderSidebar(sessions) {
465
465
  return;
466
466
  }
467
467
  sessions.forEach((s) => {
468
- const title = s.firstUser || "新对话";
468
+ const title = s.sessionName || s.firstUser || "新对话";
469
469
  const when = s.timestamp ? new Date(s.timestamp).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
470
470
  const item = el("div", {
471
471
  class: "session-item" + (s.file === state.currentSessionFile ? " active" : ""),
@@ -533,11 +533,21 @@ async function loadSession(file) {
533
533
  const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
534
534
  const data = await res.json();
535
535
 
536
+ if (!res.ok || data.error) {
537
+ showToast(`加载会话失败: ${data.error || "无法读取会话文件"}`);
538
+ showEmptyState(true);
539
+ state.currentSessionFile = null;
540
+ return;
541
+ }
542
+
536
543
  if (data.model) {
537
544
  state.currentModel = data.model;
538
545
  renderModelPill();
539
546
  }
540
547
 
548
+ const topName = data.sessionName || (data.header?.id ? baseName(file) : "新对话");
549
+ $("#topSessionName").textContent = topName;
550
+
541
551
  clearChat();
542
552
  document.querySelector("#emptyState").style.display = "none";
543
553
  const chat = $("#chat-inner");
@@ -601,11 +611,22 @@ function extractContentText(content) {
601
611
  if (typeof content === "string") return content;
602
612
  if (!Array.isArray(content)) return "";
603
613
  return content
604
- .filter(c => c.type === "text" || typeof c === "string")
605
- .map(c => typeof c === "string" ? c : c.text)
614
+ .filter(c => c && (c.type === "text" || typeof c === "string"))
615
+ .map(c => typeof c === "string" ? c : (c.text || ""))
606
616
  .join("");
607
617
  }
608
618
 
619
+ function appendSystemNotice(text) {
620
+ if (!text) return;
621
+ const chatInner = $("#chat-inner");
622
+ if (!chatInner) return;
623
+ const node = el("div", { class: "system-notice-divider" }, [
624
+ el("span", { class: "system-notice-text", text })
625
+ ]);
626
+ chatInner.appendChild(node);
627
+ scrollBottom();
628
+ }
629
+
609
630
  // ---- Chat rendering ----
610
631
  function clearChat() {
611
632
  const chatInner = $("#chat-inner");
@@ -1213,8 +1234,9 @@ function handlePiMessage(obj) {
1213
1234
  }
1214
1235
  }
1215
1236
  else if (obj.command === "cycle_thinking_level" && obj.success) {
1216
- if (obj.data?.thinkingLevel) {
1217
- state.thinkingLevel = obj.data.thinkingLevel;
1237
+ const newLevel = obj.data?.level || obj.data?.thinkingLevel;
1238
+ if (newLevel) {
1239
+ state.thinkingLevel = newLevel;
1218
1240
  renderThinkingPill();
1219
1241
  updateEmptyStateModelInfo();
1220
1242
  }
@@ -1264,6 +1286,7 @@ function handlePiMessage(obj) {
1264
1286
  finalizeStreamingMsg();
1265
1287
  state.streaming = false;
1266
1288
  setComposerAborting(false);
1289
+ sendWs({ type: "get_state" });
1267
1290
  refreshSessions(); // titles may have changed
1268
1291
  break;
1269
1292
  case "message_start": {
package/public/style.css CHANGED
@@ -811,9 +811,12 @@ body {
811
811
  .input-group {
812
812
  display: flex;
813
813
  gap: 8px;
814
+ width: 100%;
815
+ box-sizing: border-box;
814
816
  }
815
817
  .input-group input {
816
818
  flex: 1;
819
+ min-width: 0;
817
820
  background: var(--bg-input);
818
821
  border: 1px solid var(--border);
819
822
  border-radius: 10px;
@@ -822,6 +825,7 @@ body {
822
825
  font-size: 13px;
823
826
  font-family: "SF Mono", Menlo, Consolas, monospace;
824
827
  outline: none;
828
+ box-sizing: border-box;
825
829
  }
826
830
  .input-group input:focus {
827
831
  border-color: var(--text-dim);
@@ -837,6 +841,8 @@ body {
837
841
  cursor: pointer;
838
842
  white-space: nowrap;
839
843
  transition: background 0.15s;
844
+ flex-shrink: 0;
845
+ box-sizing: border-box;
840
846
  }
841
847
  .btn-primary:hover { background: var(--accent-hover); }
842
848
 
@@ -1205,9 +1211,9 @@ body {
1205
1211
 
1206
1212
  /* Topbar Mobile Adjustments — fixed, taller, shadowed so it is always findable */
1207
1213
  .topbar {
1208
- height: 54px;
1209
- padding: 0 12px;
1210
- gap: 8px;
1214
+ height: 52px;
1215
+ padding: 0 8px;
1216
+ gap: 6px;
1211
1217
  position: fixed;
1212
1218
  top: 0;
1213
1219
  left: 0;
@@ -1219,36 +1225,37 @@ body {
1219
1225
  }
1220
1226
 
1221
1227
  .main {
1222
- padding-top: 54px;
1228
+ padding-top: 52px;
1223
1229
  }
1224
1230
 
1225
1231
  .topbar .session-name {
1226
- font-size: 14px;
1232
+ font-size: 13px;
1227
1233
  font-weight: 500;
1228
1234
  min-width: 0;
1229
1235
  flex: 1;
1230
1236
  overflow: hidden;
1231
1237
  text-overflow: ellipsis;
1232
1238
  white-space: nowrap;
1233
- padding: 0 4px;
1239
+ padding: 0 2px;
1234
1240
  }
1235
1241
 
1236
1242
  .btn-toggle-sidebar {
1237
- width: 40px;
1238
- height: 40px;
1243
+ width: 36px;
1244
+ height: 36px;
1239
1245
  padding: 0;
1240
1246
  color: var(--text);
1241
1247
  background: var(--bg-hover);
1248
+ flex-shrink: 0;
1242
1249
  }
1243
1250
  .btn-toggle-sidebar svg {
1244
- width: 22px;
1245
- height: 22px;
1251
+ width: 20px;
1252
+ height: 20px;
1246
1253
  }
1247
1254
 
1248
1255
  #cwdPillWrap {
1249
- padding: 5px 9px;
1256
+ padding: 4px 7px;
1250
1257
  font-size: 11px;
1251
- max-width: 90px;
1258
+ max-width: 75px;
1252
1259
  overflow: hidden;
1253
1260
  text-overflow: ellipsis;
1254
1261
  white-space: nowrap;
@@ -1256,25 +1263,65 @@ body {
1256
1263
  }
1257
1264
 
1258
1265
  .model-pill-container {
1259
- max-width: 130px;
1266
+ display: flex;
1267
+ align-items: center;
1268
+ gap: 4px;
1269
+ flex-shrink: 0;
1270
+ max-width: none;
1260
1271
  }
1261
1272
 
1262
1273
  .model-pill {
1263
- padding: 5px 8px;
1274
+ padding: 4px 8px;
1264
1275
  font-size: 11px;
1265
- max-width: 90px;
1276
+ max-width: 105px;
1266
1277
  overflow: hidden;
1267
1278
  text-overflow: ellipsis;
1268
1279
  white-space: nowrap;
1269
1280
  flex-shrink: 0;
1281
+ gap: 4px;
1270
1282
  }
1271
1283
  .model-pill-name {
1272
- max-width: 50px;
1284
+ max-width: 65px;
1285
+ }
1286
+ .model-pill-badge {
1287
+ display: none !important;
1273
1288
  }
1274
1289
 
1275
1290
  .thinking-pill {
1276
- padding: 5px 7px;
1291
+ padding: 4px 7px;
1277
1292
  font-size: 11px;
1293
+ flex-shrink: 0;
1294
+ white-space: nowrap;
1295
+ gap: 3px;
1296
+ display: inline-flex;
1297
+ align-items: center;
1298
+ }
1299
+ .thinking-pill .thinking-icon {
1300
+ font-size: 12px;
1301
+ }
1302
+ .thinking-pill .thinking-label {
1303
+ font-size: 11px;
1304
+ font-weight: 500;
1305
+ }
1306
+
1307
+ @media (max-width: 360px) {
1308
+ .topbar {
1309
+ padding: 0 4px;
1310
+ gap: 4px;
1311
+ }
1312
+ #cwdPillWrap {
1313
+ max-width: 50px;
1314
+ padding: 4px 5px;
1315
+ }
1316
+ .model-pill {
1317
+ max-width: 90px;
1318
+ }
1319
+ .model-pill-name {
1320
+ max-width: 50px;
1321
+ }
1322
+ .thinking-pill {
1323
+ padding: 4px 5px;
1324
+ }
1278
1325
  }
1279
1326
 
1280
1327
  /* Model & Thinking Dropdown Menu Mobile Adjustments */
@@ -1395,6 +1442,58 @@ body {
1395
1442
  height: 32px;
1396
1443
  }
1397
1444
 
1445
+ /* CWD Modal Mobile Adjustments */
1446
+ .modal {
1447
+ width: 92%;
1448
+ max-width: 440px;
1449
+ padding: 16px;
1450
+ border-radius: 14px;
1451
+ gap: 12px;
1452
+ box-sizing: border-box;
1453
+ }
1454
+ .modal-header h3 {
1455
+ font-size: 15px;
1456
+ }
1457
+ .modal-desc {
1458
+ font-size: 12px;
1459
+ }
1460
+ .input-group {
1461
+ display: flex;
1462
+ flex-direction: row;
1463
+ gap: 6px;
1464
+ width: 100%;
1465
+ box-sizing: border-box;
1466
+ }
1467
+ .input-group input {
1468
+ font-size: 13px;
1469
+ padding: 9px 10px;
1470
+ min-width: 0;
1471
+ }
1472
+ .btn-primary {
1473
+ padding: 0 14px;
1474
+ font-size: 12px;
1475
+ height: 38px;
1476
+ display: inline-flex;
1477
+ align-items: center;
1478
+ justify-content: center;
1479
+ flex-shrink: 0;
1480
+ }
1481
+ .quick-dirs .chips .chip {
1482
+ font-size: 11px;
1483
+ padding: 5px 8px;
1484
+ word-break: break-all;
1485
+ }
1486
+
1487
+ @media (max-width: 380px) {
1488
+ .input-group {
1489
+ flex-direction: column;
1490
+ gap: 8px;
1491
+ }
1492
+ .btn-primary {
1493
+ width: 100%;
1494
+ }
1495
+ }
1496
+
1398
1497
  .composer-hint {
1399
1498
  font-size: 10px;
1400
1499
  margin-top: 6px;
package/server.js CHANGED
@@ -551,6 +551,19 @@ app.post("/api/set-default-model", async (req, res) => {
551
551
  current.defaultThinkingLevel = thinkingLevel;
552
552
  }
553
553
 
554
+ // If enabledModels is configured in settings.json, ensure the new default model
555
+ // is in the enabledModels list, otherwise Pi core startup logic will fall back
556
+ // to enabledModels[0] instead of using defaultModel.
557
+ if (Array.isArray(current.enabledModels) && current.enabledModels.length > 0) {
558
+ const fullId = `${provider}/${modelId}`;
559
+ const inList = current.enabledModels.some(item =>
560
+ item === fullId || item === modelId || item === `${provider}/*` || item === "*"
561
+ );
562
+ if (!inList) {
563
+ current.enabledModels.unshift(fullId);
564
+ }
565
+ }
566
+
554
567
  await writeFile(settingsPath, JSON.stringify(current, null, 2), "utf8");
555
568
 
556
569
  res.json({
@@ -604,11 +617,13 @@ async function listAllSessionFiles() {
604
617
  // Root-level .jsonl files (created when we pass --session-dir to pi).
605
618
  const top = await readdir(SESSIONS_DIR, { withFileTypes: true }).catch(() => []);
606
619
  for (const e of top) {
607
- if (e.isFile() && e.name.endsWith(".jsonl")) files.push(path.join(SESSIONS_DIR, e.name));
620
+ if ((e.isFile() || e.isSymbolicLink()) && e.name.endsWith(".jsonl")) {
621
+ files.push(path.join(SESSIONS_DIR, e.name));
622
+ }
608
623
  }
609
624
 
610
625
  // Subdirectory .jsonl files (created by pi itself when cwd is encoded).
611
- const subdirs = top.filter(d => d.isDirectory());
626
+ const subdirs = top.filter(d => d.isDirectory() || d.isSymbolicLink());
612
627
  for (const d of subdirs) {
613
628
  const dp = path.join(SESSIONS_DIR, d.name);
614
629
  const names = (await readdir(dp).catch(() => [])).filter(f => f.endsWith(".jsonl"));
@@ -629,11 +644,14 @@ async function getSessionMetadata(file) {
629
644
 
630
645
  const content = await readFile(file, "utf8");
631
646
  const lines = content.split("\n").filter(Boolean);
632
- let header = null, title = null, msgCount = 0;
647
+ let header = null, title = null, sessionName = null, msgCount = 0;
633
648
  for (const line of lines) {
634
649
  let o;
635
650
  try { o = JSON.parse(line); } catch { continue; }
636
651
  if (o.type === "session") header = o;
652
+ if (o.type === "session_info" && o.name) {
653
+ sessionName = o.name.trim();
654
+ }
637
655
  if (o.type === "message" && o.message && o.message.role === "user" && !title) {
638
656
  title = extractText(o.message.content).slice(0, 80);
639
657
  }
@@ -649,6 +667,7 @@ async function getSessionMetadata(file) {
649
667
  file,
650
668
  name: path.basename(file),
651
669
  id: header.id,
670
+ sessionName: sessionName || null,
652
671
  timestamp: header.timestamp,
653
672
  firstUser: title,
654
673
  messageCount: msgCount,
@@ -662,15 +681,18 @@ app.get("/api/sessions", async (req, res) => {
662
681
  try {
663
682
  const cwd = normalizeCwd(req.query.cwd);
664
683
  const all = await listAllSessionFiles();
665
- const sessions = [];
666
- for (const full of all) {
667
- try {
668
- const meta = await getSessionMetadata(full);
669
- if (meta && meta.cwd === cwd) {
670
- sessions.push(meta.sessionInfo);
671
- }
672
- } catch {}
673
- }
684
+ const results = await Promise.all(
685
+ all.map(async (full) => {
686
+ try {
687
+ const meta = await getSessionMetadata(full);
688
+ if (meta && meta.cwd === cwd) {
689
+ return meta.sessionInfo;
690
+ }
691
+ } catch {}
692
+ return null;
693
+ })
694
+ );
695
+ const sessions = results.filter(Boolean);
674
696
  sessions.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
675
697
  res.json({ cwd, sessions });
676
698
  } catch (e) {
@@ -684,7 +706,7 @@ function extractText(content) {
684
706
  if (!Array.isArray(content)) return "";
685
707
  return content
686
708
  .filter(c => c && (c.type === "text" || typeof c === "string"))
687
- .map(c => typeof c === "string" ? c : c.text)
709
+ .map(c => typeof c === "string" ? c : (c.text || ""))
688
710
  .join("");
689
711
  }
690
712
 
@@ -695,7 +717,11 @@ app.get("/api/session", async (req, res) => {
695
717
  if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
696
718
 
697
719
  // Security check: ensure the file path is within SESSIONS_DIR
698
- const resolvedFile = path.resolve(file);
720
+ let resolvedFile = file;
721
+ if (resolvedFile.startsWith("~")) {
722
+ resolvedFile = path.join(home(), resolvedFile.slice(1));
723
+ }
724
+ resolvedFile = path.resolve(resolvedFile);
699
725
  const resolvedSessionsDir = path.resolve(SESSIONS_DIR);
700
726
  const relPath = path.relative(resolvedSessionsDir, resolvedFile);
701
727
  if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
@@ -706,9 +732,13 @@ app.get("/api/session", async (req, res) => {
706
732
  const lines = content.split("\n").filter(Boolean);
707
733
  const entries = [];
708
734
  let header = null;
735
+ let sessionName = null;
709
736
  for (const line of lines) {
710
737
  let o; try { o = JSON.parse(line); } catch { continue; }
711
738
  if (o.type === "session") header = o;
739
+ if (o.type === "session_info" && o.name) {
740
+ sessionName = o.name.trim();
741
+ }
712
742
  entries.push(o);
713
743
  }
714
744
  // Build a map and reconstruct the active path from root -> leaf.
@@ -762,7 +792,7 @@ app.get("/api/session", async (req, res) => {
762
792
  }
763
793
  }
764
794
 
765
- res.json({ header, entries: entryChain, model: sessionModel });
795
+ res.json({ header, entries: entryChain, model: sessionModel, sessionName });
766
796
  } catch (e) {
767
797
  console.error(e);
768
798
  res.status(500).json({ error: String(e) });