@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.
Files changed (95) hide show
  1. package/README.md +4 -4
  2. package/README.zh-CN.md +4 -4
  3. package/docs/README.md +8 -0
  4. package/docs/doc_asset/client-overview.webp +0 -0
  5. package/docs/doc_asset/multi-window.webp +0 -0
  6. package/docs/{pi-desk/plugins.md → plugins.md} +108 -1
  7. package/package.json +2 -2
  8. package/src/client/l2_biz/project-preview/l2-project-preview-dialog.tsx +3 -0
  9. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-view.tsx +8 -0
  10. package/src/client/l2_biz/workbench/hooks/l2-workbench-chat-viewport.ts +81 -7
  11. package/src/client/l2_biz/workbench/l2-workbench-chat-view.tsx +2 -1
  12. package/src/client/l3_modules/project-files/l3-project-document-git.ts +82 -0
  13. package/src/client/l3_modules/project-files/l3-project-document-view.tsx +102 -5
  14. package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +2 -6
  15. package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +83 -1
  16. package/src/client/l3_modules/project-files/l3-project-file-tabs.tsx +24 -2
  17. package/src/client/l3_modules/project-files/l3-project-git-model.ts +32 -0
  18. package/src/client/l4_foundation/ui/code/l4-code-diff.tsx +25 -5
  19. package/src/client/l4_foundation/ui/code/l4-code-line-changes.ts +49 -0
  20. package/src/client/l4_foundation/ui/code/l4-code-line-changes.worker.ts +10 -0
  21. package/src/client/l4_foundation/ui/code/l4-code-line-markers.module.css +60 -0
  22. package/src/client/l4_foundation/ui/code/l4-code-line-markers.ts +156 -0
  23. package/src/client/l4_foundation/ui/code/l4-code-preview.tsx +61 -5
  24. package/src/client/l4_foundation/ui/code/l4-monaco-lines-diff.d.ts +21 -0
  25. package/src/client/l4_foundation/ui/shadcn/hover-card.tsx +28 -2
  26. package/src/common/l3_modules/project-files/l3-project-files-locale-messages.ts +16 -0
  27. package/src/server/l4_foundation/pidesk/l4-pidesk-runtime.ts +1 -1
  28. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  29. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  30. package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
  31. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +5 -4
  32. package/temp/build/pi-desk/release/.next/required-server-files.js +3 -3
  33. package/temp/build/pi-desk/release/.next/required-server-files.json +3 -3
  34. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  35. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  36. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  37. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  38. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  39. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  40. package/temp/build/pi-desk/release/.next/server/app/_not-found/page.js +1 -1
  41. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  42. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  43. package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
  44. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  45. package/temp/build/pi-desk/release/.next/server/app/page.js +6 -6
  46. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  47. package/temp/build/pi-desk/release/.next/server/chunks/20842.js +1 -0
  48. package/temp/build/pi-desk/release/.next/server/chunks/25406.js +1 -1
  49. package/temp/build/pi-desk/release/.next/server/chunks/{9248.js → 354.js} +1 -1
  50. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +6 -6
  51. package/temp/build/pi-desk/release/.next/server/chunks/45671.js +1 -0
  52. package/temp/build/pi-desk/release/.next/server/chunks/47757.js +1 -1
  53. package/temp/build/pi-desk/release/.next/server/chunks/56284.js +62 -0
  54. package/temp/build/pi-desk/release/.next/server/chunks/73691.js +1 -0
  55. package/temp/build/pi-desk/release/.next/server/chunks/82499.js +2 -2
  56. package/temp/build/pi-desk/release/.next/server/chunks/94296.js +2 -2
  57. package/temp/build/pi-desk/release/.next/server/chunks/pi-desk-line-changes.js +1 -0
  58. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  59. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  60. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  61. package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
  62. package/temp/build/pi-desk/release/.next/server/webpack-runtime.js +1 -1
  63. package/temp/build/pi-desk/release/.next/static/chunks/28739.540a4f24a8eaa3fd.js +1 -0
  64. package/temp/build/pi-desk/release/.next/static/chunks/31087-333cae396a52f232.js +1 -0
  65. package/temp/build/pi-desk/release/.next/static/chunks/3814-35cfab0ce3aa7c41.js +1 -0
  66. package/temp/build/pi-desk/release/.next/static/chunks/38459.9a9b6f4f3ec3ff50.js +1 -0
  67. package/temp/build/pi-desk/release/.next/static/chunks/45992-a39a014497ec4dd1.js +13 -0
  68. package/temp/build/pi-desk/release/.next/static/chunks/53291.ab6ef49016107124.js +1 -0
  69. package/temp/build/pi-desk/release/.next/static/chunks/{54206.82a63ab971911eda.js → 54206.ffb8c581c6661ae2.js} +2 -2
  70. package/temp/build/pi-desk/release/.next/static/chunks/5598.f34dce2a9de0d0e3.js +1 -0
  71. package/temp/build/pi-desk/release/.next/static/chunks/70669.8b4231a8675499e0.js +1 -0
  72. package/temp/build/pi-desk/release/.next/static/chunks/95287.34b860abf26f64a5.js +1 -0
  73. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-586f6f88483051a7.js +3 -0
  74. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-c6c68b33156be10a.js → page-0f2cf68a4666420b.js} +4 -4
  75. package/temp/build/pi-desk/release/.next/static/chunks/pi-desk-line-changes.49f8d59c256621d6.js +1 -0
  76. package/temp/build/pi-desk/release/.next/static/chunks/webpack-26261285e181235a.js +1 -0
  77. package/temp/build/pi-desk/release/.next/static/css/{f6a76a493aae9ae3.css → adb1d4e48dd43c0a.css} +1 -1
  78. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  79. package/docs/pi-desk/README.md +0 -47
  80. package/temp/build/pi-desk/release/.next/server/chunks/6656.js +0 -1
  81. package/temp/build/pi-desk/release/.next/server/chunks/89236.js +0 -62
  82. package/temp/build/pi-desk/release/.next/static/chunks/19991.02fcb7c8ca3b1fe4.js +0 -1
  83. package/temp/build/pi-desk/release/.next/static/chunks/26285.54b8bcbf7a198c84.js +0 -1
  84. package/temp/build/pi-desk/release/.next/static/chunks/52354-6461358c522130be.js +0 -1
  85. package/temp/build/pi-desk/release/.next/static/chunks/74132-876f256c7a6101af.js +0 -13
  86. package/temp/build/pi-desk/release/.next/static/chunks/79615.7d68b269d93fcb93.js +0 -1
  87. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-4faf3a52d219296d.js +0 -3
  88. package/temp/build/pi-desk/release/.next/static/chunks/webpack-7bfbf627379fb4c1.js +0 -1
  89. /package/docs/{pi-desk/commands.md → commands.md} +0 -0
  90. /package/docs/{pi-desk/examples → examples}/local-application/browser/application.js +0 -0
  91. /package/docs/{pi-desk/examples → examples}/local-application/browser/entry.js +0 -0
  92. /package/docs/{pi-desk/examples → examples}/local-application/package.json +0 -0
  93. /package/docs/{pi-desk/examples → examples}/local-application/pi-desk.ts +0 -0
  94. /package/temp/build/pi-desk/release/.next/static/{1LdtGYUckBhYZy0R-6yG0 → -pemk30NXpiExEU5tjb1t}/_buildManifest.js +0 -0
  95. /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/images/client-overview.webp">
27
- <img src="./docs/images/client-overview.webp" alt="File preview" width="300" />
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/images/multi-window.webp">
30
- <img src="./docs/images/multi-window.webp" alt="Side-by-side conversations" width="300" />
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/images/client-overview.webp">
27
- <img src="./docs/images/client-overview.webp" alt="文件预览" width="300" />
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/images/multi-window.webp">
30
- <img src="./docs/images/multi-window.webp" alt="多窗口会话" width="300" />
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
@@ -0,0 +1,8 @@
1
+ # Pi Desk 文档
2
+
3
+ Pi Desk 基于 Pi 开发,底层能力参照对应版本的 Pi 文档。
4
+
5
+ | 任务 | 文档 |
6
+ | ------------------------------ | ------------------------- |
7
+ | 管理 Pi Desk、安装或重载插件 | [管理命令](./commands.md) |
8
+ | 开发插件、应用、面板和消息视图 | [插件开发](./plugins.md) |
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
- 消息适配需要时读 `/entry` 的 `declareMessage` 与 `/browser` 的 Message View 类型:Node 投影原始 Pi 消息,Browser 按最终 `viewKey + priority` 选择 View。首屏必需数据放 Summary,大内容按 Detail 获取;View 失败只影响相应消息,不自行解析 Pi 原始类型或切换到另一个低优先级 View。
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.5",
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/pi-desk",
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
- if (!ready || !visible) return
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 = false
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
+ }