pi-web-ui 0.83.0 → 0.84.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
@@ -10,7 +10,29 @@
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
- 暂无其他未发布内容。
13
+ ## [0.84.0] — 2026-09-13
14
+
15
+ ### Added
16
+
17
+ - **Git 视图的左栏可以拖宽了**(issue #139)—— 改动文件 / 提交历史那一栏原来固定 300px,路径或分支名一长就被截断且无处可展。现在拖它右边的分隔条即可调宽(**双击复位**),宽度**跨会话记住**(`localStorage`);拖动时按容器宽度收敛,保证 diff 区不会被挤没(额外还有一条 `max-width` 兜底)。
18
+ - 与左右主面板同一套手感(拖动分隔条、双击复位、松开才写存档);纯函数 `web/src/scm-sidebar.ts` + 单测 `tests/unit/scm-sidebar.test.ts`,真浏览器回归 `tests/scm-test.mjs`(拖 +120px → 刷新后保持 → 双击回 300px)。
19
+
20
+ ### Fixed
21
+
22
+ - **正在聊的那条对话不再从左栏「运行的对话」里消失**(issue #140)—— 原来新对话只有「被换到后台且仍在流式输出」时才入列,于是你在当前对话里发消息时,左栏里根本没有它;只开一条对话时连「运行的对话」这个标题都不渲染,观感像是对话丢了。现在**当前对话一旦有内容**(有消息、或已被首条提示词命名)就立刻出现在列表里(还在流式输出时也会亮起绿点),行上标「当前」、点击是安全的空动作。
23
+ - **空白新对话仍然不入列**(连点「新建对话」不会堆出一排空条目),历史对话列表也不受影响。
24
+ - 这是**纯展示口径**:`listed` 的语义,以及「被挤出后台时是保留还是释放 runtime」「每项目 8 个上限」这些规则完全没变(换走时该释放的仍然释放);列表里当前对话那一行的 ✕ 按同一口径判定,真能移出(不再静默无反应)。
25
+ - 回归:`tests/unit/…` 无(口径在服务端),零 token 协议测试 `tests/running-list-test.mjs`(空白不入列 / 流式中入列 / 跑完留住 / 当前行 ✕ 可移出 / 后台运行语义不变),真浏览器 `tests/panel-layout-test.mjs`(区标题、「当前」副标签、流式绿点、只有一行)。DSH 引擎同口径一并改了(共用同一套左栏与 wire 协议)。
26
+
27
+ - **切项目时左栏不再闪一下项目名**(#140 那个改动的回归)—— 切项目会自动切到该项目的对话;如果切过去时那个项目只有一条对话(就是这条当前对话),列表里只有一组,它顶上原来会闪出一行项目名再消失(实测约 8ms 一帧)。原因是两个信号不同时到达:`conversations` 推送先到(activeId 已是新项目的对话),带新 `cwd` 的快照后到,只按 `cwd` 分组的那一帧就把当前项目当成了「别的项目」。
28
+ - 现在**只要当前对话在列表里,就认它所在的分组为当前项目**(`currentCwd` 只在它不在列表里时——空白新对话——作为回落),并直接置顶,顺带免掉随后的位置跳动;其他项目的后台运行照旧显示项目名。
29
+ - 分组逻辑抽成纯函数 `web/src/conv-groups.ts` + 单测 `tests/unit/conv-groups.test.ts`,另有真浏览器逐帧回归 `tests/conv-group-flash-test.mjs`(MutationObserver 记录每一帧 DOM,两个方向都断言不再渲染项目名;改回只看 `cwd` 即变红)。
30
+
31
+ <!-- auto-i18n:start -->
32
+ ### i18n
33
+
34
+ - 服务端新增 key(1):`terminals.cwd.outside.workspace`
35
+ <!-- auto-i18n:end -->
14
36
 
15
37
  ## [0.83.0] — 2026-09-13
16
38
 
@@ -686,7 +708,8 @@
686
708
  - 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
687
709
  - 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
688
710
 
689
- [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.83.0...main
711
+ [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.84.0...main
712
+ [0.84.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.84.0
690
713
  [0.83.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.83.0
691
714
  [0.80.1]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.80.1
692
715
  [0.80.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.80.0
@@ -20,7 +20,7 @@ import { createRequire } from "node:module";
20
20
  import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
21
21
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
- import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
23
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
  import { BgServerTracker } from "./bg-servers.js";
26
26
  import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, } from "./update-check.js";
@@ -133,7 +133,7 @@ export function makeKillableBashTool(cwd, kills,
133
133
  /** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
134
134
  lang = () => "en") {
135
135
  const base = createLocalBashOperations();
136
- const tool = createBashTool(cwd, {
136
+ const tool = createBashToolDefinition(cwd, {
137
137
  operations: {
138
138
  exec: async (command, c, opts) => {
139
139
  const ac = new AbortController();
@@ -151,7 +151,7 @@ lang = () => "en") {
151
151
  },
152
152
  },
153
153
  });
154
- // AgentTool ToolDefinition (same fields; customTools expects definitions).
154
+ // Keep the SDK definition so execute receives the current session context.
155
155
  return {
156
156
  name: tool.name,
157
157
  label: tool.label,
@@ -175,8 +175,8 @@ lang = () => "en") {
175
175
  }),
176
176
  prepareArguments: tool.prepareArguments,
177
177
  executionMode: tool.executionMode,
178
- execute: async (toolCallId, params, signal, onUpdate) => {
179
- const result = (await tool.execute(toolCallId, params, signal, onUpdate));
178
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
179
+ const result = (await tool.execute(toolCallId, params, signal, onUpdate, ctx));
180
180
  // head/tail 后处理(native 无终端,直接截返回行即可)。
181
181
  const p = params;
182
182
  if ((p?.head || p?.tail) && result?.content?.[0]?.text != null) {
@@ -1740,8 +1740,12 @@ export class ClientSession {
1740
1740
  session: runtime.session,
1741
1741
  cwd: runtime.cwd,
1742
1742
  createdAt: Date.now(),
1743
- // A brand-new conversation is not yet in the running list — it enters
1744
- // only when it is displaced to the background while still streaming.
1743
+ // A brand-new conversation is not yet LISTED — it enters the running
1744
+ // list only when it is displaced to the background while still
1745
+ // streaming (its runtime is what `listed` protects). A blank chat is
1746
+ // also kept out of the left panel's running list; it shows up there as
1747
+ // soon as it has content while it is the active chat — see
1748
+ // shownInRunningList (#140).
1745
1749
  listed: false,
1746
1750
  promptedSinceActive: false,
1747
1751
  lastActiveAt: Date.now(),
@@ -2327,6 +2331,10 @@ export class ClientSession {
2327
2331
  const task = conv.pendingTask;
2328
2332
  conv.pendingTask = undefined;
2329
2333
  this.emitRun(conv, task ? { type: "run_start", task } : { type: "run_start" });
2334
+ // #140:本轮真的开跑了(session.isStreaming 此刻已为 true)—— 左栏
2335
+ // 那行的「流式中」标识要立刻亮起来:首条提示词的入列 emit 早于本轮
2336
+ // 启动,那时它还是 false;后台对话被唤醒重跑也靠这里刷新。
2337
+ this.emitConversations();
2330
2338
  break;
2331
2339
  }
2332
2340
  case "turn_start": {
@@ -3523,6 +3531,8 @@ export class ClientSession {
3523
3531
  if (conv.title === DEFAULT_CONV_TITLE && text.trim() && !conv.session.sessionName?.trim()) {
3524
3532
  const trimmed = text.trim().replace(/\s+/g, " ");
3525
3533
  conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
3534
+ // 同时也是 #140 的入列时刻:这条对话从此有内容了,左栏「运行的对话」
3535
+ // 立刻要有它(此刻还在流式输出,不能等 agent_end 的防抖刷新)。
3526
3536
  this.emitConversations();
3527
3537
  }
3528
3538
  // Attach files as independent nextTurn context messages (asides) so the
@@ -4128,10 +4138,40 @@ export class ClientSession {
4128
4138
  this.notifyConversationChanged();
4129
4139
  this.flushSnapshot();
4130
4140
  }
4141
+ /** 左栏「运行的对话」的展示口径(issue #140)。
4142
+ *
4143
+ * 老口径只有 listed:新对话要等「被换到后台且仍在跑」才入列 —— 用户正在聊的
4144
+ * 那条反而不在列表里(只有它一条时,左栏连「运行的对话」标题都不渲染,观感
4145
+ * 像是对话丢了)。新口径把「当前对话 + 已经有内容」也算进来:有消息的对话
4146
+ * (或已被首条提示词命名 —— 命名与首条消息是同一时刻,见 prompt() 里的
4147
+ * rename 块)立刻出现在列表里;空白新对话仍然不入列(防连点「新建对话」
4148
+ * 堆出一排空条目)。
4149
+ *
4150
+ * 只影响「列表里推什么」:listed 本身的语义、以及 displaceActive /
4151
+ * shouldRetainActive / MAX_OPEN_CONVERSATIONS 那套「什么算运行中」的规则
4152
+ * 完全不变(换走时该释放的仍然释放,不会被这次展示口径改动永久钉在列表里)。
4153
+ * Display-only: retention and disposal rules are deliberately untouched. */
4154
+ shownInRunningList(conv) {
4155
+ if (conv.listed)
4156
+ return true;
4157
+ if (conv.id !== this.activeId)
4158
+ return false;
4159
+ // 首条提示词给对话命名 = 用户真的开始聊了(此刻消息可能还没落进会话统计)。
4160
+ if (conv.title !== DEFAULT_CONV_TITLE)
4161
+ return true;
4162
+ try {
4163
+ return conv.session.getSessionStats().totalMessages > 0;
4164
+ }
4165
+ catch {
4166
+ // 会话替换中 —— 先不列,下一次 emit 会补上
4167
+ return false;
4168
+ }
4169
+ }
4131
4170
  /** Push every running conversation across ALL projects to the client. The
4132
4171
  * running-conversation list is global so a background run from another
4133
4172
  * workspace stays visible; clicking one switches both the conversation and
4134
- * its project (see switchConversation). The client groups the list by cwd. */
4173
+ * its project (see switchConversation). The client groups the list by cwd.
4174
+ * 推什么见 shownInRunningList(listed + 当前对话有内容时)。 */
4135
4175
  emitConversations() {
4136
4176
  const conversations = [];
4137
4177
  // Active parents are normally absent from Running. Keep them visible while
@@ -4141,7 +4181,7 @@ export class ClientSession {
4141
4181
  .map((conv) => conv.parentId)
4142
4182
  .filter(Boolean));
4143
4183
  for (const conv of this.convs.values()) {
4144
- if (!conv.listed && !visibleParents.has(conv.id))
4184
+ if (!this.shownInRunningList(conv) && !visibleParents.has(conv.id))
4145
4185
  continue;
4146
4186
  let messageCount = 0;
4147
4187
  let isStreaming = false;
@@ -4462,8 +4502,9 @@ export class ClientSession {
4462
4502
  });
4463
4503
  return;
4464
4504
  }
4465
- if (!conv.listed) {
4466
- // Not in list anyway — nothing to do.
4505
+ if (!this.shownInRunningList(conv)) {
4506
+ // Not in list anyway — nothing to do. 展示口径见 shownInRunningList
4507
+ // (当前对话有内容时也在列表里,对它的 ✕ 必须真的移出,不能静默 no-op)。
4467
4508
  this.emitConversations();
4468
4509
  return;
4469
4510
  }
@@ -1121,11 +1121,18 @@ export class DshClientSession {
1121
1121
  pendingMessages() {
1122
1122
  return 0;
1123
1123
  }
1124
+ /** 左栏展示口径(issue #140,与 pi 引擎同义):listed 之外,当前对话只要有
1125
+ * 内容(DSH 的对话消息全在内存里,直接数 messages)也在列表里;空白新对话
1126
+ * 不入列。只影响展示,不动 listed 的语义。 */
1127
+ shownInRunningList(conv) {
1128
+ return conv.id === this.activeId && conv.messages.length > 0;
1129
+ }
1124
1130
  emitConversations() {
1125
1131
  const list = [];
1126
1132
  for (const conv of this.convs.values()) {
1127
- // 只列被置换到后台的运行中会话(与 pi 一致);active 会话不进“运行的对话”。
1128
- if (!conv.listed)
1133
+ // 被置换到后台的会话 + 当前对话(一旦有内容就是用户正在聊的那条,
1134
+ // 不该在列表里缺席——issue #140;空白新对话仍然不入列)。
1135
+ if (!conv.listed && !this.shownInRunningList(conv))
1129
1136
  continue;
1130
1137
  list.push({
1131
1138
  id: conv.id,
@@ -1797,7 +1804,7 @@ export class DshClientSession {
1797
1804
  });
1798
1805
  return;
1799
1806
  }
1800
- if (!conv.listed) {
1807
+ if (!conv.listed && !this.shownInRunningList(conv)) {
1801
1808
  this.emitConversations();
1802
1809
  return;
1803
1810
  }
@@ -678,7 +678,7 @@ export class TerminalManager {
678
678
  this.history.delete(id);
679
679
  const safeCwd = this.safeCwd(cwd || fallbackCwd);
680
680
  if (!safeCwd) {
681
- this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
681
+ this.fail(id, pick(this.lang?.() ?? "en", "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace", "terminals.cwd.outside.workspace"), "Terminal cwd must be inside the current workspace");
682
682
  return null;
683
683
  }
684
684
  if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash, opts?.locale)) {
@@ -720,7 +720,7 @@ export class TerminalManager {
720
720
  const command = expandPwd(def.command.trim(), pwd);
721
721
  const title = def.name || command || `终端 ${++this.seq}`;
722
722
  if (!dir) {
723
- this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
723
+ this.fail(id, pick(this.lang?.() ?? "en", "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace", "terminals.cwd.outside.workspace"), "Terminal cwd must be inside the current workspace");
724
724
  return;
725
725
  }
726
726
  if (existing) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.83.0",
3
+ "version": "0.84.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -1,4 +1,4 @@
1
- import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-D__1RagK.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-w24rH0km.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(h,u)=>(c=Number(u),`\r
1
+ import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-B1_0cm7B.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-w24rH0km.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(h,u)=>(c=Number(u),`\r
2
2
  `)).replace(/\r?\n?\x1b\[90m\[(?:进程已退出,退出码 |Process exited with code )-?\d+\]\x1b\[0m\r?\n?/g,`\r
3
3
  `),exitCode:c}}function ce({conversationId:t,terminalId:c,command:s,cwd:h,title:u,active:w,running:f,exitCode:k,register:g}){const C=l.useRef(null),j=l.useRef(null),{locale:o}=P(),y=l.useRef(o);y.current=o;const E=s?JSON.stringify(s):"";l.useEffect(()=>{const m=C.current;if(!m)return;const r=new re({theme:O(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),v=new ie;r.loadAddon(v),r.open(m),j.current={term:r,fit:v},w&&r.focus();const b=()=>{r.options.theme=O()};window.addEventListener(M,b),r.attachCustomKeyEventHandler(d=>{if(d.type!=="keydown")return!0;const S=d.key?.toLowerCase();if((d.ctrlKey||d.metaKey)&&S==="v")return!1;if(d.ctrlKey&&!d.shiftKey&&!d.altKey&&S==="c"&&r.hasSelection()){const D=r.textarea;return D&&(D.value=r.getSelection(),D.select()),!1}return!0});const _=g(t,c,{write:d=>r.write(le(d).clean),dispose:()=>r.dispose()}),$=()=>{try{v.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.cols,rows:r.rows})}catch{}},B=requestAnimationFrame(()=>{try{v.fit()}catch{}s?p({type:"run_command",terminalId:c,conversationId:t,command:s,cols:r.cols,rows:r.rows}):p({type:"terminal_create",terminalId:c,title:u,locale:y.current,conversationId:t,cwd:h,cols:r.cols,rows:r.rows})}),R=r.onData(d=>{p({type:"terminal_input",terminalId:c,conversationId:t,data:d})});let T=null;return typeof ResizeObserver<"u"&&(T=new ResizeObserver(()=>{m.offsetWidth>0&&m.offsetHeight>0&&$()}),T.observe(m)),()=>{cancelAnimationFrame(B),R.dispose(),window.removeEventListener(M,b),T?.disconnect(),_(),r.dispose(),j.current=null}},[t,c,E,g]),l.useEffect(()=>{if(!w)return;const m=requestAnimationFrame(()=>{const r=j.current;if(r){try{r.fit.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.term.cols,rows:r.term.rows})}catch{}r.term.focus()}});return()=>cancelAnimationFrame(m)},[w]);const{t:F}=P(),x=l.useRef(void 0);return l.useEffect(()=>{if(f===x.current||(x.current=f,f!==!1))return;const m=j.current;m&&m.term.write(`\r
4
4
  \x1B[90m${F("exitBanner",{code:k??""})}\x1B[0m\r