gft-merge 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -2,65 +2,80 @@
2
2
 
3
3
  # GFT-Merge
4
4
 
5
- Pull sessions from different AI coding tools into a **pool**, then start a new session in any of them from that pool. Runs locally; your data stays on your machine.
5
+ ## Carry context between your AI coding sessions.
6
6
 
7
- Works with **Claude Code** and **Codex** today.
7
+ **Pull sessions from Claude Code and Codex into a pool, then start a new session in either tool with that context already in it.**
8
+
9
+ You plan something in one Claude Code session, build it in Codex, and debug it in another session. Handing over means copying and pasting by hand. GFT-Merge reads your local sessions, lets you combine the ones that matter, and starts a fresh session whose first message carries them, so the AI picks up where you left off.
10
+
11
+ It runs on your computer and opens in your browser. A sibling of [GFT-Map](https://github.com/CoralLips/gft-map).
12
+
13
+ > The interface is in Chinese for now.
8
14
 
9
15
  ## Get started
10
16
 
11
- You need Node.js 22 or newer, and Claude Code and/or Codex installed and signed in.
17
+ You'll need **Node.js 22+** and at least one of:
18
+
19
+ - **Claude Code** in the **Claude desktop app**, signed in. New Claude sessions are brought into the app through its own `claude://` link, so the app is required to see them.
20
+ - **Codex**, the Codex app or CLI, signed in. With the Codex app, new sessions also open there.
12
21
 
13
- ```bash
22
+ Then run:
23
+
24
+ ```sh
14
25
  npx gft-merge
15
26
  ```
16
27
 
17
- Your browser opens `http://127.0.0.1:4319`. You can also install it globally and run `gft-merge`.
28
+ Your browser opens `http://127.0.0.1:4319`. Run it again any time; it reuses the running service.
18
29
 
19
30
  ## How it works
20
31
 
21
- - **Top**: one bucket per tool, listing the last 30 days of sessions. The Claude list matches the Claude app: archived and deleted sessions are left out and names follow the app; sessions started in a terminal are marked "CLI".
22
- - **Bottom**: your pools on the left, the selected pool's workspace on the right.
23
- - **Drag down to add**: a session dropped into the workspace becomes a ball; dropped on a ball, it fuses into it.
24
- - **Drop a ball on a ball to fuse**: any two balls can be fused.
25
- - **Drag up to start a session**: drop a ball on a tool's bucket to start a new session there whose first message is the ball's content.
26
- - **Click a ball to compress**: one model call, using the tool and model chosen as the processor in settings. While it runs the ball's dashed ring turns and its name reads "压缩中…". The result is one piece that can't be split again; you can fuse more sessions onto it or compress it again. Drag it up before compression finishes and the original text is sent.
27
- - **The number in a ball**: a single session shows that session's own context size, read from the tool's records; a fused or compressed ball shows how many tokens dragging it up would send, counted locally (≈).
28
- - **Names and contents**: the label under a ball lists its sessions, each prefixed with its tool. Hover the label to see the contents; drag a session out of that list to split it off.
29
- - **Move and remove**: drop a ball on another pool in the list to move it; drop it outside the workspace to remove it.
32
+ The page has two halves. At the top, one column per tool lists your sessions from the last 30 days. Below, your pools on the left and the selected pool's workspace on the right. Everything is done by dragging.
33
+
34
+ | What you want | How |
35
+ | --------------------------------- | ----------------------------------------------------------------------------------------------- |
36
+ | Put a session in a pool | Drag it from a tool's column into the workspace. It becomes a ball. |
37
+ | Combine sessions | Drop one ball on another. Any two balls can be fused. |
38
+ | Start a session with that context | Drag a ball up onto a tool's column. A new session starts there, named "merge · pool name". |
39
+ | Make a big ball smaller | Click the ball to compress it with a model (see below). |
40
+ | See what a ball holds | Hover the name under it. Drag a session out of that list to split it off. |
41
+ | Move or remove | Drop a ball on another pool in the list to move it; drop it outside the workspace to remove it. |
30
42
 
31
- ## What happens when you drag up
43
+ **The number in a ball** is its size in tokens. A single session shows that session's own context size, read from the tool's records. A fused or compressed ball shows what dragging it up would send, counted locally (marked ≈).
32
44
 
33
- GFT-Merge starts a session in that tool (named "merge · pool name"), sends the content as its first message, and asks the AI to report briefly what it understood (what the sessions are about, where things stand, what is decided and open, what comes next) and then wait for you. That is a real turn, so the new session sits at the top of the tool's list. No hooks are installed, no tool settings are changed, and nothing runs inside the tools' own processing.
45
+ **Compression** sends the ball's content to the tool and model chosen under **Settings → 处理工具** (processing tool) and replaces it with the result. While it runs, the ball's ring turns and its name reads "压缩中…" (compressing). A compressed ball can't be split again; you can fuse more sessions onto it or compress it again. Drag it up before compression finishes and the original text is sent.
34
46
 
35
- - **Codex**: returns as soon as the message is sent; once the reply ends, the session opens in the Codex app through its own `codex://` link.
36
- - **Claude Code**: waits for Claude's reply (from several seconds to a minute or so, depending on length), then imports and opens the session in the Claude app through its own `claude://resume` link.
37
- - The new session opens in the project most of its sources came from.
47
+ **The new session's first message** contains the plain conversation of every session in the ball (your messages and the final answer of each turn, without tool output) and asks the AI to report briefly what it understood (what the sessions are about, where things stand, what is decided and open, what comes next), then wait for you. That reply is a real turn, so the session sits at the top of the tool's list. With Codex the page returns at once and the session opens when the reply ends; with Claude the page waits for the reply (several seconds to about a minute), then the session opens in the Claude app.
38
48
 
39
- Why not send into an existing session: Codex refuses other programs writing to a session that is open in the Codex app, and the Claude app shows its own cached copy, not what another program writes into a session file. Neither path is reliable, so only new sessions are supported.
49
+ **Why only new sessions:** Codex refuses other programs writing to a session that is open in the Codex app, and the Claude app shows its own copy of a session, not what another program writes into it. Neither is reliable, so GFT-Merge always starts a new session.
40
50
 
41
- Notes:
51
+ ## Where does your data go?
42
52
 
43
- - What is sent is the plain conversation: your messages and the final answer of each turn, without intermediate steps or tool logs. A model is called only when you compress, and that uses your quota.
44
- - Uncompressed content is sent in full; a long pool can fill the new session's context, so compress it first.
45
- - Tested on Windows so far; opening the desktop apps' links on macOS and Linux is not verified.
53
+ - GFT-Merge reads your local Claude Code and Codex session files. It does not change them, and it does not install hooks or change either tool's settings.
54
+ - Pools are stored in `~/.gft-merge/` on your computer (set `GFT_MERGE_HOME` to move them).
55
+ - A model is called only in two cases, both started by you: dragging a ball up (the new session runs in that tool, on your account) and compressing (one request to the processing tool, on your account).
56
+ - No GFT account, no cloud.
46
57
 
47
58
  ## Commands
48
59
 
49
- `open` (default) · `serve` · `stop` · `status`, with an optional `--port`.
60
+ ```sh
61
+ npx gft-merge # open the page, starting the service if needed
62
+ npx gft-merge stop # stop the service
63
+ npx gft-merge status # show whether it runs, and where the data is
64
+ ```
50
65
 
51
- ## Data
66
+ Add `--port <number>` to use another port.
52
67
 
53
- Stored in `~/.gft-merge/` (set `GFT_MERGE_HOME` to change):
68
+ ## Project status and feedback
54
69
 
55
- - `pools/<id>.json`: the pool's name, its balls, and each ball's sessions and compacts.
56
- - `pools/<id>.jsonl`: the pool's messages, one per line. When sessions are compressed their messages are removed and only the compact remains; the original sessions in each tool are untouched.
57
- - `settings.json`: which tools are enabled, and the processor's tool and model.
70
+ Version 0.1, an early release. Tested on Windows; on macOS and Linux, opening sessions in the desktop apps is not verified yet. Supports Claude Code and Codex.
58
71
 
59
- Everything GFT-Merge sends starts with `〔GFT-Merge〕`; merging skips such messages, so content does not snowball back and forth.
72
+ Share problems or ideas in [Issues](https://github.com/CoralLips/gft-merge/issues).
60
73
 
61
74
  ## Development
62
75
 
63
- ```bash
76
+ ```sh
77
+ git clone https://github.com/CoralLips/gft-merge.git
78
+ cd gft-merge
64
79
  npm install
65
80
  npm test
66
81
  node cli.mjs open
package/README_ZH.md CHANGED
@@ -2,65 +2,78 @@
2
2
 
3
3
  # GFT-Merge
4
4
 
5
- 把不同 AI 工具里的会话合进一个**池子**,再拿池子在任一工具里开一个新会话接着干。本地运行,数据只在本机。
5
+ ## 让上下文在 AI 编程会话之间流动。
6
6
 
7
- 目前支持 **Claude Code** 和 **Codex**。
7
+ **把 Claude Code 和 Codex 的会话拉进一个池子,再在任一工具里开一个新会话,上下文已经带在里面。**
8
+
9
+ 你在一个 Claude Code 会话里定方案,在 Codex 里写代码,又在另一个会话里排查问题。每次交接都得手动复制粘贴。GFT-Merge 读取你本机的会话,让你把有用的几段合在一起,然后开一个新会话、把它们作为第一条消息带进去,AI 就能接着往下干。
10
+
11
+ 在你自己的电脑上运行,在浏览器里打开。和 [GFT-Map](https://github.com/CoralLips/gft-map) 是同一系列的工具。
8
12
 
9
13
  ## 开始
10
14
 
11
- 需要 Node.js 22 以上,以及已经装好并登录的 Claude Code 和/或 Codex。
15
+ 需要 **Node.js 22 以上**,以及下面至少一个:
16
+
17
+ - **Claude 桌面应用里的 Claude Code**,已登录。新建的 Claude 会话要通过桌面应用自己的 `claude://` 链接导入,所以必须有桌面应用才能看到。
18
+ - **Codex**,桌面应用或命令行都可以,已登录。装了 Codex 桌面应用的话,新会话也会在里面打开。
12
19
 
13
- ```bash
20
+ 然后运行:
21
+
22
+ ```sh
14
23
  npx gft-merge
15
24
  ```
16
25
 
17
- 浏览器会打开 `http://127.0.0.1:4319`。也可以全局安装后运行 `gft-merge`。
26
+ 浏览器会打开 `http://127.0.0.1:4319`。之后随时再运行一次即可,已经在跑的服务会直接复用。
27
+
28
+ ## 怎么用
18
29
 
19
- ## 用法
30
+ 页面分上下两半。上面每个工具一栏,列出最近 30 天的会话;下面左边是池子列表,右边是选中池子的工作区。所有操作都靠拖。
20
31
 
21
- - **上面**:每个工具一栏,列出最近 30 天的会话。Claude 的列表跟桌面端一致:已归档、已删除的不列,名字用桌面端的;终端里开的会话标「CLI」。
22
- - **下面**:左边是池子列表,右边是选中池子的工作区。
23
- - **往下拖=放进池子**:会话拖进工作区就成一个球;拖到已有的球上就融合进去。
24
- - **球拖到球上=融合**:任意两个球都能融合。
25
- - **往上拖=新建**:把一个球拖到某个工具的区域上,就在那个工具里新建一个会话,第一条消息就是这个球的内容。
26
- - **点一下球=压缩**:用设置里选的“处理工具”(哪个工具的哪个模型)调一次模型,把球的内容压成一份。压缩中球的虚线圈会转,名字处显示“压缩中…”。压完成为一个整体,不能再拆;可以继续往上融合新的会话,也可以再压。压缩没完成时拖出去,送的是原文。
27
- - **球上的数字**:单个会话的球显示这个会话本身的上下文大小(直接读自工具的记录);融合或压缩过的球显示拖出去要送的内容的 token 数,由本地分词器数出来,前面带 ≈。
28
- - **名字和成分**:球下方写着里面的会话,前面带工具名;鼠标悬停在名字上显示成分,从列表里把某个会话拖出去就能拆开。
29
- - **移动和移出**:把球拖到左边另一个池子就搬过去;拖出工作区就移出池子。
32
+ | 想做什么 | 怎么做 |
33
+ | ---------------------- | -------------------------------------------------------------------- |
34
+ | 把会话放进池子 | 从上面的工具栏拖进工作区,它就成了一个球 |
35
+ | 合并几个会话 | 把一个球拖到另一个球上,任意两个球都能融合 |
36
+ | 带着这些上下文开新会话 | 把球往上拖到某个工具的栏上,就在那里新建会话,名为「merge · 池子名」 |
37
+ | 把大球变小 | 点一下球,用模型压缩(见下文) |
38
+ | 看球里有什么 | 鼠标悬停在球下方的名字上;从列表里把某个会话拖出去就能拆开 |
39
+ | 搬走或移出 | 把球拖到左边另一个池子上就搬过去;拖出工作区就移出池子 |
30
40
 
31
- ## 往上拖时会发生什么
41
+ **球里的数字**是 token 数。单个会话的球显示这个会话本身的上下文大小,直接读自工具的记录;融合或压缩过的球显示往上拖时要送出去的量,由本地分词器数出来,前面带 ≈。
32
42
 
33
- GFT-Merge 在那个工具里新建会话(名为「merge · 池子名」),把内容作为第一条消息发进去,并请 AI 先简要汇报它的理解(在做什么、进展到哪、定了什么、没定什么、下一步),然后等你的指示。这是一轮真对话,所以新会话排在工具列表最上面。不装钩子、不改各工具的配置、不插手它们的处理过程。
43
+ **压缩**会把球的内容发给 **设置 → 处理工具** 里选的工具和模型,用结果替换原内容。压缩中球的虚线圈会转,名字处显示“压缩中…”。压完的球不能再拆,但可以继续往上融合新的会话,也可以再压。压缩没完成时往上拖,送出去的是原文。
34
44
 
35
- - **Codex**:新建并发出后立刻返回;回复结束后用 Codex 桌面端自己的 `codex://` 链接打开这个会话。
36
- - **Claude Code**:等 Claude 回完(几秒到一分钟左右,看内容长短),再用桌面端自己的 `claude://resume` 链接把会话导入桌面端并打开。
37
- - 新会话放在来源会话最多的那个项目目录。
45
+ **新会话的第一条消息**包含球里每个会话的原话(你说的话和 AI 每轮的最终回答,不含工具输出),并请 AI 先简要汇报它的理解(这些会话在做什么、进展到哪、定了什么、没定什么、下一步做什么),然后等你的指示。这一轮是真对话,所以新会话排在工具列表最上面。Codex 那边页面立刻返回,回复结束后在 Codex 里打开;Claude 那边要等回复结束(几秒到一分钟左右),然后在 Claude 桌面应用里打开。
38
46
 
39
- 为什么不支持发进已有会话:Codex 会话在 Codex 桌面端里开着时,Codex 不让别的程序往里写;Claude 桌面端显示的是自己缓存的内容,别的程序写进会话文件的消息它不显示。两条路都靠不住,所以只做新建。
47
+ **为什么只做新建:** Codex 会话在 Codex 桌面应用里开着时,Codex 不让别的程序往里写;Claude 桌面应用显示的是它自己保存的会话内容,别的程序写进去的消息它不显示。两条路都靠不住,所以 GFT-Merge 只新建会话。
40
48
 
41
- 注意:
49
+ ## 数据去哪了
42
50
 
43
- - 送的是精简原话:你说的话和 AI 每轮的最终回答,不含中间过程和工具日志。只有你点了压缩,才会调用模型、花你的额度。
44
- - 没压缩的内容全文送;很长时会占满对方的上下文,可以先压缩。
45
- - 目前只在 Windows 上实测过;macOS 和 Linux 上打开桌面端的链接没有验证。
51
+ - GFT-Merge 读取你本机 Claude Code 和 Codex 的会话文件,不修改它们,也不装钩子、不改两家工具的任何设置。
52
+ - 池子存在你电脑上的 `~/.gft-merge/`(可用 `GFT_MERGE_HOME` 改位置)。
53
+ - 只有两种情况会调用模型,都由你触发:往上拖新建会话(在那个工具里、用你的账号额度),以及压缩(向处理工具发一次请求,用你的账号额度)。
54
+ - 不需要 GFT 账号,不上云。
46
55
 
47
56
  ## 命令
48
57
 
49
- `open`(默认)· `serve` · `stop` · `status`,可加 `--port`。
58
+ ```sh
59
+ npx gft-merge # 打开页面,需要时自动启动服务
60
+ npx gft-merge stop # 停止服务
61
+ npx gft-merge status # 查看是否在运行、数据在哪
62
+ ```
50
63
 
51
- ## 数据
64
+ 加 `--port <端口>` 可以换端口。
52
65
 
53
- 默认在 `~/.gft-merge/`(可用 `GFT_MERGE_HOME` 改):
66
+ ## 项目状态和反馈
54
67
 
55
- - `pools/<id>.json`:池子名、里面的球、每个球里的会话和压缩块。
56
- - `pools/<id>.jsonl`:池子里的消息,一行一条。被压缩的会话,原消息随之移除,只留压缩块;原会话本身在各工具里不受影响。
57
- - `settings.json`:启用哪些工具、处理工具用哪个工具的哪个模型。
68
+ 0.1 版,早期版本。在 Windows 上实测过;macOS 和 Linux 上在桌面应用里打开会话还没验证。支持 Claude Code 和 Codex。
58
69
 
59
- 送出的内容以 `〔GFT-Merge〕` 开头;再次合并时会跳过这类消息,防止来回同步越滚越大。
70
+ 问题和想法欢迎提在 [Issues](https://github.com/CoralLips/gft-merge/issues)。
60
71
 
61
72
  ## 开发
62
73
 
63
- ```bash
74
+ ```sh
75
+ git clone https://github.com/CoralLips/gft-merge.git
76
+ cd gft-merge
64
77
  npm install
65
78
  npm test
66
79
  node cli.mjs open
package/cli.mjs CHANGED
@@ -8,16 +8,19 @@ import { fileURLToPath } from 'node:url';
8
8
  import { homeDir, readJson, writeJson } from './home.mjs';
9
9
  import { DEFAULT_PORT, startServer } from './server.mjs';
10
10
 
11
- const [command = 'open', ...rest] = process.argv.slice(2);
11
+ // The command may be left out, as in `gft-merge --port 4329`: then it is `open`.
12
+ const args = process.argv.slice(2);
13
+ const command = args[0] && !args[0].startsWith('--') ? args[0] : 'open';
14
+ const portAt = args.indexOf('--port');
12
15
  const port =
13
- Number(rest[rest.indexOf('--port') + 1]) || Number(process.env.GFT_MERGE_PORT) || DEFAULT_PORT;
16
+ (portAt >= 0 && Number(args[portAt + 1])) || Number(process.env.GFT_MERGE_PORT) || DEFAULT_PORT;
14
17
  const url = `http://127.0.0.1:${port}`;
15
18
  const serviceFile = path.join(homeDir(), 'service.json');
16
19
  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
17
20
 
18
21
  const portTaken = () =>
19
22
  new Error(
20
- `端口 ${port} 已被其他程序占用,GFT-Merge 没有启动。请换一个端口,例如:node cli.mjs open --port ${port + 10}`,
23
+ `端口 ${port} 已被其他程序占用,GFT-Merge 没有启动。请换一个端口,例如:npx gft-merge --port ${port + 10}`,
21
24
  );
22
25
 
23
26
  /** Whether anything listens on the port, without speaking HTTP to it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gft-merge",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Merge AI agent sessions into pools and start a new session from a pool in any tool.",
5
5
  "keywords": [
6
6
  "ai",
package/skill/SKILL.md CHANGED
@@ -9,10 +9,10 @@ description: 把多个 AI 工具(Claude Code、Codex)的会话合进池子
9
9
 
10
10
  ## 打开
11
11
 
12
- 在 GFT-Merge 目录运行:
12
+ 运行:
13
13
 
14
14
  ```bash
15
- node cli.mjs open
15
+ npx gft-merge
16
16
  ```
17
17
 
18
18
  然后告诉用户页面地址(默认 `http://127.0.0.1:4319`)。不要替用户拖动或送内容。
package/tools/claude.mjs CHANGED
@@ -252,8 +252,9 @@ export function createClaudeTool({
252
252
  sdk = defaultSdk,
253
253
  desktopDir = defaultDesktopDir(),
254
254
  projectsDir = defaultProjectsDir(),
255
- open = openLink,
256
255
  executable = appClaudeCode(),
256
+ // Without the Claude app nothing handles claude:// links; opening one would only raise an error.
257
+ open = executable ? openLink : () => {},
257
258
  } = {}) {
258
259
  // Every Claude Code this tool starts runs without the parent session's
259
260
  // variables, and is the Claude app's own copy when there is one.
package/tools/codex.mjs CHANGED
@@ -218,7 +218,8 @@ const TURN_MS = 15 * 60 * 1000;
218
218
  export function createCodexTool({
219
219
  connect = connectAppServer,
220
220
  retryMs = 500,
221
- open = openLink,
221
+ // Without the Codex app nothing handles codex:// links; opening one would only raise an error.
222
+ open = appCodex() || existsSync('/Applications/Codex.app') ? openLink : () => {},
222
223
  } = {}) {
223
224
  // One app-server starts at a time from here: two starting together clash over
224
225
  // Codex's state database. A clash with the Codex app starting up is retried once.
package/web/app.js CHANGED
@@ -22,6 +22,7 @@ const state = {
22
22
  sessions: {}, // tool id → listed sessions
23
23
  processors: null, // tools that can compress, with their models; read when the menu first opens
24
24
  busy: new Set(), // tool ids starting a session now
25
+ unavailable: {}, // tool id → why its sessions could not be read
25
26
  fresh: null, // key of the session just started, shown with a brief highlight
26
27
  compressing: new Set(), // ball ids this page asked to compress
27
28
  processor: null, // { tool, model } that compresses
@@ -117,7 +118,7 @@ function renderTools() {
117
118
  state.busy.has(tool.id) ? el('span', { className: 'spinner' }) : null,
118
119
  el('span', {
119
120
  className: 'count',
120
- textContent: loaded ? `${sessions.length} 个会话` : '',
121
+ textContent: loaded && !state.unavailable[tool.id] ? `${sessions.length} 个会话` : '',
121
122
  }),
122
123
  );
123
124
  const rows = sessions.map(item =>
@@ -149,7 +150,10 @@ function renderTools() {
149
150
  {},
150
151
  rows.length
151
152
  ? rows
152
- : el('li', { className: 'empty', textContent: loaded ? '最近没有会话' : '读取中…' }),
153
+ : el('li', {
154
+ className: 'empty',
155
+ textContent: !loaded ? '读取中…' : state.unavailable[tool.id] || '最近没有会话',
156
+ }),
153
157
  ),
154
158
  );
155
159
  }),
@@ -762,14 +766,24 @@ function renderList() {
762
766
  title: '改名',
763
767
  onclick: e => {
764
768
  e.stopPropagation();
765
- const input = el('input', { value: pool.name, className: 'name' });
766
- name.replaceWith(input);
767
- input.focus();
768
- input.select();
769
- input.onclick = k => k.stopPropagation();
770
- input.onkeydown = k => k.key === 'Enter' && input.blur();
771
- input.onblur = () => {
772
- const next = input.value.trim();
769
+ // The name itself becomes editable in place: Enter keeps it, Escape or an empty name drops it.
770
+ name.contentEditable = 'plaintext-only';
771
+ name.focus();
772
+ getSelection().selectAllChildren(name);
773
+ name.onclick = k => k.stopPropagation();
774
+ name.onkeydown = k => {
775
+ if (k.key === 'Enter') {
776
+ k.preventDefault();
777
+ name.blur();
778
+ }
779
+ if (k.key === 'Escape') {
780
+ name.textContent = pool.name;
781
+ name.blur();
782
+ }
783
+ };
784
+ name.onblur = () => {
785
+ name.contentEditable = 'false';
786
+ const next = name.textContent.trim();
773
787
  if (!next || next === pool.name) return render();
774
788
  pool.name = next;
775
789
  render();
@@ -906,12 +920,14 @@ let poolsSeen = '';
906
920
  async function loadPools({ quiet = false } = {}) {
907
921
  const pools = await api('GET', '/api/pools');
908
922
  const seen = JSON.stringify(pools);
909
- const changed = !quiet || seen !== poolsSeen;
923
+ // Unchanged pools are kept as they are: the page's handlers hold these very
924
+ // objects, and a fresh copy would make their changes land on stale ones.
925
+ if (quiet && seen === poolsSeen) return;
910
926
  poolsSeen = seen;
911
927
  state.pools = pools;
912
928
  if (!state.pools.some(pool => pool.id === state.selectedId))
913
929
  state.selectedId = state.pools[0]?.id ?? null;
914
- if (changed && !state.dragging) render();
930
+ if (!state.dragging) render();
915
931
  }
916
932
 
917
933
  async function loadSessions() {
@@ -919,11 +935,14 @@ async function loadSessions() {
919
935
  state.tools
920
936
  .filter(tool => state.enabled[tool.id] !== false)
921
937
  .map(async tool => {
938
+ // A tool that can't be read (not installed, say) says so in its own column, without a popup each time.
922
939
  try {
923
940
  state.sessions[tool.id] = await api('GET', `/api/sessions?tool=${tool.id}`);
941
+ delete state.unavailable[tool.id];
924
942
  } catch (error) {
925
943
  state.sessions[tool.id] = [];
926
- toast(`${tool.name}:${error.message}`);
944
+ state.unavailable[tool.id] =
945
+ `${error.message}不用这个工具的话,可以在右上角设置里关掉这一栏。`;
927
946
  }
928
947
  }),
929
948
  );
package/web/style.css CHANGED
@@ -308,6 +308,12 @@ body.carrying-up .bucket::after {
308
308
  text-overflow: ellipsis;
309
309
  white-space: nowrap;
310
310
  }
311
+ .pool-item .name[contenteditable='plaintext-only'] {
312
+ outline: none;
313
+ cursor: text;
314
+ text-overflow: clip;
315
+ border-bottom: 1px solid var(--accent);
316
+ }
311
317
  .pool-item .edit {
312
318
  visibility: hidden;
313
319
  border: none;