@jetcrab/pi-desk 1.1.5 → 1.1.8-dev
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 +4 -4
- package/README.zh-CN.md +4 -4
- package/docs/README.md +8 -0
- package/docs/doc_asset/client-overview.webp +0 -0
- package/docs/doc_asset/multi-window.webp +0 -0
- package/docs/{pi-desk/plugins.md → plugins.md} +108 -1
- package/package.json +2 -2
- package/src/client/l2_biz/project-preview/l2-project-preview-dialog.tsx +3 -0
- package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-view.tsx +8 -0
- package/src/client/l2_biz/workbench/hooks/l2-workbench-chat-viewport.ts +81 -7
- package/src/client/l2_biz/workbench/l2-workbench-chat-view.tsx +2 -1
- package/src/client/l3_modules/project-files/l3-project-document-git.ts +82 -0
- package/src/client/l3_modules/project-files/l3-project-document-view.tsx +102 -5
- package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +2 -6
- package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +83 -1
- package/src/client/l3_modules/project-files/l3-project-file-tabs.tsx +24 -2
- package/src/client/l3_modules/project-files/l3-project-git-model.ts +32 -0
- package/src/client/l4_foundation/ui/code/l4-code-diff.tsx +25 -5
- package/src/client/l4_foundation/ui/code/l4-code-line-changes.ts +49 -0
- package/src/client/l4_foundation/ui/code/l4-code-line-changes.worker.ts +10 -0
- package/src/client/l4_foundation/ui/code/l4-code-line-markers.module.css +60 -0
- package/src/client/l4_foundation/ui/code/l4-code-line-markers.ts +156 -0
- package/src/client/l4_foundation/ui/code/l4-code-preview.tsx +61 -5
- package/src/client/l4_foundation/ui/code/l4-monaco-lines-diff.d.ts +21 -0
- package/src/client/l4_foundation/ui/shadcn/hover-card.tsx +28 -2
- package/src/common/l3_modules/project-files/l3-project-files-locale-messages.ts +16 -0
- package/src/server/l4_foundation/pidesk/l4-pidesk-runtime.ts +1 -1
- package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
- package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
- package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
- package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +5 -4
- package/temp/build/pi-desk/release/.next/required-server-files.js +3 -3
- package/temp/build/pi-desk/release/.next/required-server-files.json +3 -3
- package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_not-found/page.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/page.js +6 -6
- package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/20842.js +1 -0
- package/temp/build/pi-desk/release/.next/server/chunks/25406.js +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/{9248.js → 354.js} +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/35985.js +6 -6
- package/temp/build/pi-desk/release/.next/server/chunks/45671.js +1 -0
- package/temp/build/pi-desk/release/.next/server/chunks/47757.js +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/56284.js +62 -0
- package/temp/build/pi-desk/release/.next/server/chunks/73691.js +1 -0
- package/temp/build/pi-desk/release/.next/server/chunks/82499.js +2 -2
- package/temp/build/pi-desk/release/.next/server/chunks/94296.js +2 -2
- package/temp/build/pi-desk/release/.next/server/chunks/pi-desk-line-changes.js +1 -0
- package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
- package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
- package/temp/build/pi-desk/release/.next/server/webpack-runtime.js +1 -1
- package/temp/build/pi-desk/release/.next/static/chunks/28739.540a4f24a8eaa3fd.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/31087-333cae396a52f232.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/3814-35cfab0ce3aa7c41.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/38459.9a9b6f4f3ec3ff50.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/45992-a39a014497ec4dd1.js +13 -0
- package/temp/build/pi-desk/release/.next/static/chunks/53291.ab6ef49016107124.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/{54206.82a63ab971911eda.js → 54206.ffb8c581c6661ae2.js} +2 -2
- package/temp/build/pi-desk/release/.next/static/chunks/5598.f34dce2a9de0d0e3.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/70669.8b4231a8675499e0.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/95287.34b860abf26f64a5.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/app/layout-586f6f88483051a7.js +3 -0
- package/temp/build/pi-desk/release/.next/static/chunks/app/{page-c6c68b33156be10a.js → page-0f2cf68a4666420b.js} +4 -4
- package/temp/build/pi-desk/release/.next/static/chunks/pi-desk-line-changes.49f8d59c256621d6.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/webpack-26261285e181235a.js +1 -0
- package/temp/build/pi-desk/release/.next/static/css/{f6a76a493aae9ae3.css → adb1d4e48dd43c0a.css} +1 -1
- package/temp/build/pi-desk/release/.next/trace-build +1 -1
- package/docs/pi-desk/README.md +0 -47
- package/temp/build/pi-desk/release/.next/server/chunks/6656.js +0 -1
- package/temp/build/pi-desk/release/.next/server/chunks/89236.js +0 -62
- package/temp/build/pi-desk/release/.next/static/chunks/19991.02fcb7c8ca3b1fe4.js +0 -1
- package/temp/build/pi-desk/release/.next/static/chunks/26285.54b8bcbf7a198c84.js +0 -1
- package/temp/build/pi-desk/release/.next/static/chunks/52354-6461358c522130be.js +0 -1
- package/temp/build/pi-desk/release/.next/static/chunks/74132-876f256c7a6101af.js +0 -13
- package/temp/build/pi-desk/release/.next/static/chunks/79615.7d68b269d93fcb93.js +0 -1
- package/temp/build/pi-desk/release/.next/static/chunks/app/layout-4faf3a52d219296d.js +0 -3
- package/temp/build/pi-desk/release/.next/static/chunks/webpack-7bfbf627379fb4c1.js +0 -1
- /package/docs/{pi-desk/commands.md → commands.md} +0 -0
- /package/docs/{pi-desk/examples → examples}/local-application/browser/application.js +0 -0
- /package/docs/{pi-desk/examples → examples}/local-application/browser/entry.js +0 -0
- /package/docs/{pi-desk/examples → examples}/local-application/package.json +0 -0
- /package/docs/{pi-desk/examples → examples}/local-application/pi-desk.ts +0 -0
- /package/temp/build/pi-desk/release/.next/static/{1LdtGYUckBhYZy0R-6yG0 → -pemk30NXpiExEU5tjb1t}/_buildManifest.js +0 -0
- /package/temp/build/pi-desk/release/.next/static/{1LdtGYUckBhYZy0R-6yG0 → -pemk30NXpiExEU5tjb1t}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -23,11 +23,11 @@ Pi powers the agent. Pi Desk provides the interface, with a lightweight core and
|
|
|
23
23
|
## Interface demo
|
|
24
24
|
|
|
25
25
|
<p>
|
|
26
|
-
<a href="./docs/
|
|
27
|
-
<img src="./docs/
|
|
26
|
+
<a href="./docs/doc_asset/client-overview.webp">
|
|
27
|
+
<img src="./docs/doc_asset/client-overview.webp" alt="File preview" width="300" />
|
|
28
28
|
</a>
|
|
29
|
-
<a href="./docs/
|
|
30
|
-
<img src="./docs/
|
|
29
|
+
<a href="./docs/doc_asset/multi-window.webp">
|
|
30
|
+
<img src="./docs/doc_asset/multi-window.webp" alt="Side-by-side conversations" width="300" />
|
|
31
31
|
</a>
|
|
32
32
|
</p>
|
|
33
33
|
|
package/README.zh-CN.md
CHANGED
|
@@ -23,11 +23,11 @@ Pi 负责代理运行,Pi Desk 提供围绕它的工作界面。核心保持轻
|
|
|
23
23
|
## 界面演示
|
|
24
24
|
|
|
25
25
|
<p>
|
|
26
|
-
<a href="./docs/
|
|
27
|
-
<img src="./docs/
|
|
26
|
+
<a href="./docs/doc_asset/client-overview.webp">
|
|
27
|
+
<img src="./docs/doc_asset/client-overview.webp" alt="文件预览" width="300" />
|
|
28
28
|
</a>
|
|
29
|
-
<a href="./docs/
|
|
30
|
-
<img src="./docs/
|
|
29
|
+
<a href="./docs/doc_asset/multi-window.webp">
|
|
30
|
+
<img src="./docs/doc_asset/multi-window.webp" alt="多窗口会话" width="300" />
|
|
31
31
|
</a>
|
|
32
32
|
</p>
|
|
33
33
|
|
package/docs/README.md
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -112,7 +112,114 @@ Push 可丢失、不持久化、不 ACK、不重放,也不创建聊天消息
|
|
|
112
112
|
- 当前页面轻提示:`plugin.notify({ level: 'success', title: '保存成功' })` 或 `host.notify()`,只在当前页面短暂展示,离线可错过。
|
|
113
113
|
- 服务端全局通知:Node `plugin.notifications.publish({ level: 'success', title: '处理完成', event: { name: 'open-result', data: {} } })`;用返回 ID 调用 `update/delete`。Browser Entry 通过 `plugin.notifications.onEvent('open-result', handler)` 处理点击,成功后宿主删除通知。Node 未重启时重连可恢复;Node 重启或插件释放后清空,不持久化。
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
## 消息展示
|
|
116
|
+
|
|
117
|
+
Node Entry 的 Declaration 把已有 Pi 消息转换为 `{ viewKey, summary, detail }`;Browser 的 Message View 按 `viewKey` 展示。`viewKey` 使用小写 kebab-case 的 `namespace/view-name` 形式,标识展示数据协议,不是消息 ID。Declaration 不创建消息,也不管理消息身份。
|
|
118
|
+
|
|
119
|
+
以原生插件产生的 `customType = 'example-status'` 消息为例,在 Node Entry 的 `setup(plugin)` 中注册:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
plugin.declareMessage('status', {
|
|
123
|
+
priority: 100,
|
|
124
|
+
match({ message }) {
|
|
125
|
+
return message.kind === 'custom' && message.customType === 'example-status'
|
|
126
|
+
},
|
|
127
|
+
project({ defaultProjection }) {
|
|
128
|
+
return {
|
|
129
|
+
viewKey: 'example/status',
|
|
130
|
+
summary: defaultProjection.summary,
|
|
131
|
+
detail: null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
此例复用默认自定义消息的 `summary.text`。在 Browser Entry 同步注册对应视图:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
plugin.registerContribution('message-view', 'status', {
|
|
141
|
+
viewKey: 'example/status',
|
|
142
|
+
priority: 100,
|
|
143
|
+
load: () => import('./status-view.js').then((module) => module.default)
|
|
144
|
+
})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
同目录的 `status-view.js`:
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
export default {
|
|
151
|
+
mount({ container, target }) {
|
|
152
|
+
const element = document.createElement('p')
|
|
153
|
+
container.append(element)
|
|
154
|
+
|
|
155
|
+
function render() {
|
|
156
|
+
const { text } = target.message.getSnapshot().summary
|
|
157
|
+
if (typeof text !== 'string') {
|
|
158
|
+
target.reportError(new Error('消息摘要缺少 text'))
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
element.textContent = text
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const unsubscribe = target.message.subscribe(render)
|
|
165
|
+
render()
|
|
166
|
+
return () => {
|
|
167
|
+
unsubscribe()
|
|
168
|
+
element.remove()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- `match/project` 必须同步、快速、无副作用;每次返回完整投影,不自行计算或传输增量。自定义数据的结构由对应 `viewKey` 的 Declaration/View 约定,不扩充通用消息类型。
|
|
175
|
+
- 首屏和实时必需内容放 `summary`;大内容或用户展开后才用的数据放 `detail`。需要详情时,仅在消息已有 `entryId` 且 `fixed.hasDetail` 为真后调用 `target.message.loadDetail()`;异步结果须检查挂载的 `signal`,不能写回失效视图。
|
|
176
|
+
- 视图从 `getSnapshot()` 读取完整当前状态,并通过 `subscribe()` 跟随更新;同 `viewKey` 的消息更新不重新挂载,Temporary 转 Durable 保持同一 Handle,`viewKey` 改变时会重挂。释放时解除订阅并清理自有资源。
|
|
177
|
+
- Declaration 和 View 分别按 `priority` 选择,数值越大越优先;同最高优先级冲突会报错。声明、加载或展示失败不自动尝试低优先级实现;异步展示错误通过 `target.reportError(error)` 交给宿主处理。
|
|
178
|
+
- 刷新后仍需展示的数据必须存在于最终 Pi Entry,仅通过 Tool `onUpdate()` 提供的数据不会自动持久化。更新插件时兼容已有消息结构,不靠自动重载旧会话解决兼容问题。
|
|
179
|
+
|
|
180
|
+
## 任务
|
|
181
|
+
|
|
182
|
+
Task Center 用于当前 Session 的真实任务,通过 `bindSessionPlugin()` 返回的 `plugin` 上报;Global 插件的任务由自身运行态管理,不创建 Session Task。
|
|
183
|
+
|
|
184
|
+
在真实操作开始时创建任务,完成后上报结果。以下 `runTask()` 是插件已有的业务函数:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const task = plugin.startTask({
|
|
188
|
+
title: '处理文件',
|
|
189
|
+
activity: '正在执行'
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await runTask()
|
|
194
|
+
task.complete('处理完成')
|
|
195
|
+
} catch (error) {
|
|
196
|
+
task.fail(error instanceof Error ? error.message : String(error))
|
|
197
|
+
throw error
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- 进度使用 `task.update({ activity, info })`;`info` 是 `{ label, value }[]`,整体替换,`[]` 清空,不放日志正文。
|
|
202
|
+
- 有停止能力时,给 `startTask()` 的 `interrupt` 传入真实停止函数,签名为 `() => Promise<void>`。回调返回不代表任务已经结束;确认实际停止后调用 `task.stopped()`,会话关闭等中断使用 `task.interrupted()`。
|
|
203
|
+
- 一个任务只进入一次终态,终态后不能 `update()` 或再次结束;`remove()` 只移除上报记录,不停止真实操作。
|
|
204
|
+
- `detailSource` 可选:文本日志用 `{ kind: 'text-file', path: logPath }`;活动子会话用 `{ kind: 'pi-conversation', session: childSession }`;历史会话用 `{ kind: 'pi-conversation', sessionFile }`。文件路径均为服务端绝对路径,活动实例和文件来源二选一。
|
|
205
|
+
- 宿主只观察详情,不接管插件创建的资源。会话关闭时停止自有任务、上报终态并释放子会话等资源,不关闭父会话;需要随分支结束的任务在树导航前收敛。
|
|
206
|
+
- 任务列表是运行态,不是持久历史;宿主保留全部运行中任务和最近 100 条终态,不能依赖更早记录一直存在。
|
|
207
|
+
|
|
208
|
+
## 日志
|
|
209
|
+
|
|
210
|
+
命令、构建等任务的完整输出由插件写入日志文件,并负责容量与清理。界面复用宿主日志能力,不自行读取文件或实现监听、重连。
|
|
211
|
+
|
|
212
|
+
从 `@jetcrab/pi-desk-sdk/react/base` 导入 `PluginLogViewer`,在已有 `PluginHostProvider` 内使用:
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
<PluginLogViewer path={logPath} />
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`logPath` 是服务端可访问的绝对文本文件路径。文件未创建时继续等待;组件处理追加、重写、重连与读取重试,最多显示末尾 128KB、1000 行,不修改原文件。
|
|
219
|
+
|
|
220
|
+
非 React 界面使用 `host.logs.open(logPath)`,通过返回句柄的 `getSnapshot()`、`subscribe()` 和 `retry()` 读取与观察,释放时调用 `dispose()`。Session Task 可使用 `detailSource: { kind: 'text-file', path: logPath }` 展示同一日志。
|
|
221
|
+
|
|
222
|
+
业务数据只传递必要的日志摘要和路径,不把日志正文放入 Method 返回、Plugin Push、状态槽位或 Pi JSONL。
|
|
116
223
|
|
|
117
224
|
## 更新生效
|
|
118
225
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jetcrab/pi-desk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8-dev",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"bin",
|
|
22
|
-
"docs
|
|
22
|
+
"docs",
|
|
23
23
|
"next.config.ts",
|
|
24
24
|
"package.json",
|
|
25
25
|
"src",
|
|
@@ -221,6 +221,8 @@ export function L2ProjectPreviewDialog({
|
|
|
221
221
|
const code = (
|
|
222
222
|
<div className="flex size-full min-h-0 min-w-0 flex-col">
|
|
223
223
|
<L3ProjectFileTabs
|
|
224
|
+
cwd={cwd}
|
|
225
|
+
gitRuntime={git}
|
|
224
226
|
paths={state.tabs}
|
|
225
227
|
activePath={state.activePath}
|
|
226
228
|
diffPath={diffPath}
|
|
@@ -230,6 +232,7 @@ export function L2ProjectPreviewDialog({
|
|
|
230
232
|
/>
|
|
231
233
|
<L3ProjectDocumentView
|
|
232
234
|
runtime={files}
|
|
235
|
+
gitRuntime={git}
|
|
233
236
|
compact={size.width < 1100 || narrow}
|
|
234
237
|
onEscape={onClose}
|
|
235
238
|
/>
|
|
@@ -207,6 +207,8 @@ export function L2FileWorkspaceView({
|
|
|
207
207
|
) : null}
|
|
208
208
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
209
209
|
<L2FileWorkspaceTabs
|
|
210
|
+
cwd={cwd}
|
|
211
|
+
gitRuntime={gitRuntime}
|
|
210
212
|
paths={workspace.tabs}
|
|
211
213
|
activePath={activePath}
|
|
212
214
|
diffPath={diffPath}
|
|
@@ -217,6 +219,12 @@ export function L2FileWorkspaceView({
|
|
|
217
219
|
<L3ProjectDocumentView
|
|
218
220
|
ref={documentRef}
|
|
219
221
|
runtime={runtime.getFileRuntime(workId)}
|
|
222
|
+
gitRuntime={gitRuntime}
|
|
223
|
+
visible={
|
|
224
|
+
expanded ||
|
|
225
|
+
(workspace.windowOpen &&
|
|
226
|
+
(presentation === 'split' || workspace.activePane === 'files'))
|
|
227
|
+
}
|
|
220
228
|
compact={compact}
|
|
221
229
|
onEscape={escape}
|
|
222
230
|
onSwitchPane={onSwitchPane}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useLayoutEffect, useRef } from 'react'
|
|
4
|
-
import { useStickToBottomContext } from 'use-stick-to-bottom'
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react'
|
|
4
|
+
import { useStickToBottomContext, type StickToBottomState } from 'use-stick-to-bottom'
|
|
5
5
|
import {
|
|
6
6
|
L3_CONVERSATION_TOP_GUTTER_PX,
|
|
7
7
|
clearL3ConversationTailSpacer,
|
|
@@ -14,6 +14,24 @@ import type { L2WorkbenchChatViewportSnapshot } from '../l2-workbench-chat'
|
|
|
14
14
|
|
|
15
15
|
const BOTTOM_THRESHOLD_PX = 2
|
|
16
16
|
|
|
17
|
+
function subscribePageVisibility(listener: () => void): () => void {
|
|
18
|
+
document.addEventListener('visibilitychange', listener)
|
|
19
|
+
return () => document.removeEventListener('visibilitychange', listener)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pageIsVisible(): boolean {
|
|
23
|
+
return document.visibilityState === 'visible'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function serverPageIsVisible(): boolean {
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function markProgrammaticScroll(scrollState: StickToBottomState, scrollElement: HTMLElement): void {
|
|
31
|
+
// 库的命令式 setter 同时标记程序滚动,避免把锚点定位误判为手动回底。
|
|
32
|
+
scrollState.scrollTop = scrollElement.scrollTop
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
interface L2WorkbenchChatViewportManagerProps {
|
|
18
36
|
visible?: boolean
|
|
19
37
|
running: boolean
|
|
@@ -21,6 +39,7 @@ interface L2WorkbenchChatViewportManagerProps {
|
|
|
21
39
|
containsTail: boolean
|
|
22
40
|
latestUserAnchor: string | null
|
|
23
41
|
entrySnapshot: L2WorkbenchChatViewportSnapshot | null
|
|
42
|
+
onResetToTail: () => void
|
|
24
43
|
onSaveSnapshot: (snapshot: L2WorkbenchChatViewportSnapshot) => void
|
|
25
44
|
onClearSnapshot: () => void
|
|
26
45
|
}
|
|
@@ -61,10 +80,17 @@ export function L2WorkbenchChatViewportManager({
|
|
|
61
80
|
containsTail,
|
|
62
81
|
latestUserAnchor,
|
|
63
82
|
entrySnapshot,
|
|
83
|
+
onResetToTail,
|
|
64
84
|
onSaveSnapshot,
|
|
65
85
|
onClearSnapshot
|
|
66
86
|
}: L2WorkbenchChatViewportManagerProps): null {
|
|
67
87
|
const { isAtBottom, scrollRef, scrollToBottom, state, stopScroll } = useStickToBottomContext()
|
|
88
|
+
const pageVisible = useSyncExternalStore(
|
|
89
|
+
subscribePageVisibility,
|
|
90
|
+
pageIsVisible,
|
|
91
|
+
serverPageIsVisible
|
|
92
|
+
)
|
|
93
|
+
const previousPageVisibleRef = useRef(pageVisible)
|
|
68
94
|
const positioningRef = useRef(false)
|
|
69
95
|
const initializedRef = useRef(false)
|
|
70
96
|
const positionedUserAnchorRef = useRef<string | null>(null)
|
|
@@ -98,6 +124,14 @@ export function L2WorkbenchChatViewportManager({
|
|
|
98
124
|
return
|
|
99
125
|
}
|
|
100
126
|
if (suspendedScrollTopRef.current === null) return
|
|
127
|
+
if (!running) {
|
|
128
|
+
initializedRef.current = false
|
|
129
|
+
entrySnapshotRef.current = null
|
|
130
|
+
suspendedSnapshotRef.current = null
|
|
131
|
+
suspendedScrollTopRef.current = null
|
|
132
|
+
logL3ConversationScroll('viewport:reenter-ended', scrollElement)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
101
135
|
stopScroll()
|
|
102
136
|
const snapshot = suspendedSnapshotRef.current
|
|
103
137
|
const target = snapshot
|
|
@@ -106,21 +140,31 @@ export function L2WorkbenchChatViewportManager({
|
|
|
106
140
|
if (snapshot && target)
|
|
107
141
|
positionL3ConversationMessage(scrollElement, target, snapshot.offset, containsTail)
|
|
108
142
|
else scrollElement.scrollTop = suspendedScrollTopRef.current
|
|
109
|
-
//
|
|
143
|
+
// 仍在运行时,隐藏期间的新消息不能覆盖离开前的阅读位置。
|
|
110
144
|
initializedRef.current = true
|
|
111
145
|
positionedUserAnchorRef.current = latestUserAnchor
|
|
112
146
|
suspendedSnapshotRef.current = null
|
|
113
147
|
suspendedScrollTopRef.current = null
|
|
114
|
-
}, [containsTail, latestUserAnchor, scrollRef, stopScroll, visible])
|
|
148
|
+
}, [containsTail, latestUserAnchor, running, scrollRef, stopScroll, visible])
|
|
115
149
|
|
|
116
150
|
useLayoutEffect(() => {
|
|
117
|
-
|
|
151
|
+
const returnedToPage = pageVisible && !previousPageVisibleRef.current
|
|
152
|
+
previousPageVisibleRef.current = pageVisible
|
|
153
|
+
if (returnedToPage && visible && !running) {
|
|
154
|
+
initializedRef.current = false
|
|
155
|
+
entrySnapshotRef.current = null
|
|
156
|
+
}
|
|
157
|
+
if (!ready || !visible || !pageVisible) return
|
|
118
158
|
if (!running) onClearSnapshot()
|
|
119
159
|
|
|
120
160
|
const initializing = !initializedRef.current
|
|
121
161
|
const hasNewUser =
|
|
122
162
|
latestUserAnchor !== null && latestUserAnchor !== positionedUserAnchorRef.current
|
|
123
163
|
if (!initializing && !hasNewUser) return
|
|
164
|
+
if (initializing && !running && !containsTail) {
|
|
165
|
+
onResetToTail()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
124
168
|
|
|
125
169
|
const currentScrollElement = scrollRef.current
|
|
126
170
|
if (hasNewUser) {
|
|
@@ -130,6 +174,7 @@ export function L2WorkbenchChatViewportManager({
|
|
|
130
174
|
|
|
131
175
|
let positionFrame: number | null = null
|
|
132
176
|
let releaseFrame: number | null = null
|
|
177
|
+
let releaseEntryAnchor: (() => void) | null = null
|
|
133
178
|
positioningRef.current = true
|
|
134
179
|
|
|
135
180
|
const position = (): void => {
|
|
@@ -148,7 +193,7 @@ export function L2WorkbenchChatViewportManager({
|
|
|
148
193
|
|
|
149
194
|
let target: HTMLElement | null = null
|
|
150
195
|
let targetOffset = L3_CONVERSATION_TOP_GUTTER_PX
|
|
151
|
-
const snapshot = initializing ? entrySnapshotRef.current : null
|
|
196
|
+
const snapshot = initializing && running ? entrySnapshotRef.current : null
|
|
152
197
|
if (snapshot) {
|
|
153
198
|
const savedTarget = findL3ConversationMessage(
|
|
154
199
|
scrollElement,
|
|
@@ -201,10 +246,34 @@ export function L2WorkbenchChatViewportManager({
|
|
|
201
246
|
initializedRef.current = true
|
|
202
247
|
positionedUserAnchorRef.current = latestUserAnchor
|
|
203
248
|
entrySnapshotRef.current = null
|
|
249
|
+
if (initializing && !running && target) {
|
|
250
|
+
// 首屏的懒布局仍会改变高度;用户开始操作后立即交还阅读位置。
|
|
251
|
+
const entryTarget = target
|
|
252
|
+
markProgrammaticScroll(state, scrollElement)
|
|
253
|
+
const observer = new ResizeObserver(() => {
|
|
254
|
+
stopScroll()
|
|
255
|
+
positionL3ConversationMessage(scrollElement, entryTarget, targetOffset, containsTail)
|
|
256
|
+
markProgrammaticScroll(state, scrollElement)
|
|
257
|
+
})
|
|
258
|
+
observer.observe(scrollElement)
|
|
259
|
+
if (scrollElement.firstElementChild) observer.observe(scrollElement.firstElementChild)
|
|
260
|
+
const interactionElement = scrollElement.parentElement!
|
|
261
|
+
const events = ['wheel', 'touchstart', 'pointerdown', 'keydown', 'click'] as const
|
|
262
|
+
const release = (): void => {
|
|
263
|
+
observer.disconnect()
|
|
264
|
+
for (const event of events) interactionElement.removeEventListener(event, release, true)
|
|
265
|
+
releaseEntryAnchor = null
|
|
266
|
+
positioningRef.current = false
|
|
267
|
+
}
|
|
268
|
+
for (const event of events) {
|
|
269
|
+
interactionElement.addEventListener(event, release, { passive: true, capture: true })
|
|
270
|
+
}
|
|
271
|
+
releaseEntryAnchor = release
|
|
272
|
+
}
|
|
204
273
|
releaseFrame = window.requestAnimationFrame(() => {
|
|
205
274
|
if (followAfterPosition) void scrollToBottom({ animation: 'instant' })
|
|
206
275
|
else if (target) stopScroll()
|
|
207
|
-
positioningRef.current =
|
|
276
|
+
positioningRef.current = releaseEntryAnchor !== null
|
|
208
277
|
})
|
|
209
278
|
}
|
|
210
279
|
|
|
@@ -215,15 +284,20 @@ export function L2WorkbenchChatViewportManager({
|
|
|
215
284
|
return () => {
|
|
216
285
|
if (positionFrame !== null) window.cancelAnimationFrame(positionFrame)
|
|
217
286
|
if (releaseFrame !== null) window.cancelAnimationFrame(releaseFrame)
|
|
287
|
+
releaseEntryAnchor?.()
|
|
288
|
+
positioningRef.current = false
|
|
218
289
|
}
|
|
219
290
|
}, [
|
|
220
291
|
containsTail,
|
|
221
292
|
latestUserAnchor,
|
|
222
293
|
onClearSnapshot,
|
|
294
|
+
onResetToTail,
|
|
295
|
+
pageVisible,
|
|
223
296
|
ready,
|
|
224
297
|
running,
|
|
225
298
|
scrollRef,
|
|
226
299
|
scrollToBottom,
|
|
300
|
+
state,
|
|
227
301
|
stopScroll,
|
|
228
302
|
visible
|
|
229
303
|
])
|
|
@@ -134,7 +134,7 @@ function L2WorkbenchChatViewComponent({
|
|
|
134
134
|
turns={turns}
|
|
135
135
|
active={visible}
|
|
136
136
|
focusTurnIdentity={latestUserTurn?.identity ?? null}
|
|
137
|
-
initialAnchor={entrySnapshot?.messageId ?? null}
|
|
137
|
+
initialAnchor={viewportRunning ? (entrySnapshot?.messageId ?? null) : null}
|
|
138
138
|
>
|
|
139
139
|
{(turnWindow) => (
|
|
140
140
|
<>
|
|
@@ -146,6 +146,7 @@ function L2WorkbenchChatViewComponent({
|
|
|
146
146
|
containsTail={turnWindow.containsTail}
|
|
147
147
|
latestUserAnchor={latestUserAnchor}
|
|
148
148
|
entrySnapshot={entrySnapshot}
|
|
149
|
+
onResetToTail={turnWindow.resetToTail}
|
|
149
150
|
onSaveSnapshot={saveViewportSnapshot}
|
|
150
151
|
onClearSnapshot={clearViewportSnapshot}
|
|
151
152
|
/>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useSyncExternalStore, type RefObject } from 'react'
|
|
4
|
+
import type { L3ProjectFileRuntime } from './l3-project-file-runtime'
|
|
5
|
+
import type { L3ProjectGitBrowser } from './l3-project-git-runtime'
|
|
6
|
+
import { findL3GitFile } from './l3-project-git-model'
|
|
7
|
+
|
|
8
|
+
export function useL3ProjectDocumentGit(
|
|
9
|
+
runtime: L3ProjectFileRuntime,
|
|
10
|
+
git: L3ProjectGitBrowser,
|
|
11
|
+
host: RefObject<HTMLDivElement | null>,
|
|
12
|
+
savePosition: () => void,
|
|
13
|
+
active: boolean
|
|
14
|
+
): { refresh: () => void; unavailable: boolean } {
|
|
15
|
+
useSyncExternalStore(git.subscribe, git.getRevision, git.getRevision)
|
|
16
|
+
const snapshot = git.getWorkspace(runtime.cwd)?.snapshot
|
|
17
|
+
const path = runtime.state.activePath
|
|
18
|
+
const diffActive = Boolean(runtime.state.activeDiff)
|
|
19
|
+
const document = path ? runtime.state.documents.get(path) : undefined
|
|
20
|
+
const save = useRef(savePosition)
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
save.current = savePosition
|
|
23
|
+
}, [savePosition])
|
|
24
|
+
const refresh = useCallback((): void => {
|
|
25
|
+
if (!path || diffActive) return
|
|
26
|
+
save.current()
|
|
27
|
+
runtime.reloadFile(path)
|
|
28
|
+
void git.refreshSnapshot(runtime.cwd)
|
|
29
|
+
}, [diffActive, git, path, runtime])
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!active || !path || diffActive) return
|
|
33
|
+
const visible = (): boolean =>
|
|
34
|
+
window.document.visibilityState === 'visible' &&
|
|
35
|
+
Boolean(host.current?.getClientRects().length) &&
|
|
36
|
+
!host.current?.closest('[inert], [aria-hidden="true"]')
|
|
37
|
+
let lastRefresh = Date.now()
|
|
38
|
+
if (visible()) {
|
|
39
|
+
// 已打开的标签重新展示时重读;首次打开仍复用正在进行的正文请求。
|
|
40
|
+
if (runtime.state.documents.get(path)?.status === 'text') {
|
|
41
|
+
save.current()
|
|
42
|
+
runtime.reloadFile(path)
|
|
43
|
+
}
|
|
44
|
+
void git.refreshSnapshot(runtime.cwd)
|
|
45
|
+
}
|
|
46
|
+
const onFocus = (): void => {
|
|
47
|
+
if (!visible() || Date.now() - lastRefresh < 1000) return
|
|
48
|
+
lastRefresh = Date.now()
|
|
49
|
+
refresh()
|
|
50
|
+
}
|
|
51
|
+
window.addEventListener('focus', onFocus)
|
|
52
|
+
window.document.addEventListener('visibilitychange', onFocus)
|
|
53
|
+
return () => {
|
|
54
|
+
window.removeEventListener('focus', onFocus)
|
|
55
|
+
window.document.removeEventListener('visibilitychange', onFocus)
|
|
56
|
+
}
|
|
57
|
+
}, [active, diffActive, git, host, path, refresh, runtime])
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!active || !path || diffActive || document?.status !== 'text') return
|
|
61
|
+
const controller = new AbortController()
|
|
62
|
+
const file =
|
|
63
|
+
snapshot?.status === 'ready' ? findL3GitFile(runtime.cwd, path, snapshot.repositories) : null
|
|
64
|
+
void runtime.compareFile(
|
|
65
|
+
path,
|
|
66
|
+
file?.repository.state === 'ready' && file.change
|
|
67
|
+
? { repositoryRoot: file.repository.root, path: file.change.path }
|
|
68
|
+
: null,
|
|
69
|
+
controller.signal
|
|
70
|
+
)
|
|
71
|
+
return () => controller.abort()
|
|
72
|
+
}, [active, diffActive, document, path, runtime, snapshot])
|
|
73
|
+
|
|
74
|
+
const file =
|
|
75
|
+
path && snapshot?.status === 'ready'
|
|
76
|
+
? findL3GitFile(runtime.cwd, path, snapshot.repositories)
|
|
77
|
+
: null
|
|
78
|
+
return {
|
|
79
|
+
refresh,
|
|
80
|
+
unavailable: snapshot?.status === 'error' || file?.repository.state === 'unavailable'
|
|
81
|
+
}
|
|
82
|
+
}
|