@jetcrab/pi-desk 1.1.10 → 1.1.12-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 (38) hide show
  1. package/docs/plugins.md +1 -1
  2. package/package.json +2 -2
  3. package/src/client/l2_biz/plugin-host/l2-plugin-management.tsx +42 -34
  4. package/src/client/l2_biz/plugin-host/views/l2-plugin-installed-list.tsx +86 -82
  5. package/src/client/l2_biz/workbench/hooks/l2-workbench-chat-viewport.ts +42 -27
  6. package/src/client/l2_biz/workbench/l2-workbench.tsx +4 -7
  7. package/src/common/l3_modules/project-files/l3-project-files-contract.ts +9 -2
  8. package/src/server/l4_foundation/file/l4-work-session-project-file.ts +5 -4
  9. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  10. package/temp/build/pi-desk/release/.next/build-manifest.json +2 -2
  11. package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
  12. package/temp/build/pi-desk/release/.next/required-server-files.js +3 -3
  13. package/temp/build/pi-desk/release/.next/required-server-files.json +3 -3
  14. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  15. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  16. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  17. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  18. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  19. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  20. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  21. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  22. package/temp/build/pi-desk/release/.next/server/app/api/project-files/reveal/route.js +1 -1
  23. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  24. package/temp/build/pi-desk/release/.next/server/app/page.js +6 -6
  25. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  26. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +1 -1
  27. package/temp/build/pi-desk/release/.next/server/chunks/74528.js +1 -1
  28. package/temp/build/pi-desk/release/.next/server/chunks/8584.js +1 -1
  29. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  30. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  31. package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
  32. package/temp/build/pi-desk/release/.next/static/chunks/{45992-304a49aaaebd853e.js → 45992-92d8b8b10fcfbb6b.js} +2 -2
  33. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-9f478658c50efdbe.js → page-92d5bd2854005e6c.js} +4 -4
  34. package/temp/build/pi-desk/release/.next/static/css/4a5dda7ff3a82c4a.css +1 -0
  35. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  36. package/temp/build/pi-desk/release/.next/static/css/8a41765636e09e52.css +0 -1
  37. /package/temp/build/pi-desk/release/.next/static/{ZYtCj4W63FjXCrTLTDbvO → qFbsHgU3sMdDOeAhU56jN}/_buildManifest.js +0 -0
  38. /package/temp/build/pi-desk/release/.next/static/{ZYtCj4W63FjXCrTLTDbvO → qFbsHgU3sMdDOeAhU56jN}/_ssgManifest.js +0 -0
package/docs/plugins.md CHANGED
@@ -77,7 +77,7 @@ plugin.addPreviewDirectory(directory, { default: true })
77
77
 
78
78
  `directory` 为服务端绝对目录;省略第二个参数只登记,`default: true` 同时设为默认预览目录,后登记的默认生效。原 CWD 始终可选;目录规范化后去重,最多登记 32 个。重复快照不会覆盖用户的手动选择。登记只在当前会话运行态内,插件在 `session_start`、`session_tree` 从自身数据恢复后重新登记;不要在普通查询时重复请求默认选择。
79
79
 
80
- 调用不等待回执;非法路径或超限由宿主拒绝并记日志,实际目录内容按需读取。旧宿主或独立 Pi 不处理该登记;兼容旧 SDK 时使用 `plugin.addPreviewDirectory?.(...)`。已有 `files.preview/reveal/readImage` 的相对路径仍以会话 CWD 为基准。
80
+ 调用不等待回执;非法路径或超限由宿主拒绝并记日志,实际目录内容按需读取。旧宿主或独立 Pi 不处理该登记;兼容旧 SDK 时使用 `plugin.addPreviewDirectory?.(...)`。`files.preview/reveal` 支持会话 CWD 下规范相对路径或服务端绝对文件路径(包括项目外文件);`preview` 的 `imagePaths` 使用相同路径规则。宿主仍校验当前 Source,目录浏览边界不变。依赖项目外文件的插件需要同步更新宿主,旧宿主只接受项目内相对路径。`readImage` 仍只接受会话 CWD 下规范相对路径。
81
81
 
82
82
  ## Browser Entry 与懒界面
83
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jetcrab/pi-desk",
3
- "version": "1.1.10",
3
+ "version": "1.1.12-dev",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -69,7 +69,7 @@
69
69
  "viewerjs": "^1.11.8",
70
70
  "ws": "^8.21.3",
71
71
  "zod": "^4.4.3",
72
- "@jetcrab/pi-desk-sdk": "^1.1.2"
72
+ "@jetcrab/pi-desk-sdk": "^1.1.3-dev"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@earendil-works/pi-agent-core": "1.2.0",
@@ -333,41 +333,43 @@ export function L2PluginManagement({
333
333
  <TabsContent
334
334
  value="installed"
335
335
  keepMounted
336
- className="pi-desk-chat-scrollbar min-h-0 overflow-y-auto p-4 data-[hidden]:hidden sm:p-6"
336
+ className="flex min-h-0 flex-col overflow-hidden p-4 data-[hidden]:hidden sm:p-6"
337
337
  >
338
- <div className="space-y-4">
338
+ <div className="flex min-h-0 flex-1 flex-col gap-4">
339
339
  {direct.open ? (
340
- <L2PluginDirectInstall
341
- source={direct.source}
342
- isNpm={Boolean(direct.spec)}
343
- multiple={direct.sources.length > 1}
344
- tag={direct.tag}
345
- selectedVersion={direct.selectedVersion}
346
- onTag={direct.setTag}
347
- detail={direct.detail}
348
- loading={direct.loading}
349
- pending={directPending}
350
- disabled={disabled}
351
- error={direct.error ?? (directErrors || null)}
352
- operationMessage={
353
- directPending
354
- ? (operationMessage(directPlugin?.operation?.phase) ?? t('submitting'))
355
- : null
356
- }
357
- override={direct.override}
358
- registry={direct.registry}
359
- onSource={direct.changeSource}
360
- onMode={direct.setOverride}
361
- onRegistry={direct.setRegistry}
362
- onRead={() => void direct.loadDetail()}
363
- onVersion={(version) => void direct.loadDetail(version)}
364
- onInstall={() =>
365
- void direct.install(management.requestInstall, (items) =>
366
- management.setConfirmation({ kind: 'batch', input: { action: 'add', items } })
367
- )
368
- }
369
- onClose={() => direct.setOpen(false)}
370
- />
340
+ <div className="pi-desk-chat-scrollbar max-h-1/2 shrink-0 overflow-y-auto">
341
+ <L2PluginDirectInstall
342
+ source={direct.source}
343
+ isNpm={Boolean(direct.spec)}
344
+ multiple={direct.sources.length > 1}
345
+ tag={direct.tag}
346
+ selectedVersion={direct.selectedVersion}
347
+ onTag={direct.setTag}
348
+ detail={direct.detail}
349
+ loading={direct.loading}
350
+ pending={directPending}
351
+ disabled={disabled}
352
+ error={direct.error ?? (directErrors || null)}
353
+ operationMessage={
354
+ directPending
355
+ ? (operationMessage(directPlugin?.operation?.phase) ?? t('submitting'))
356
+ : null
357
+ }
358
+ override={direct.override}
359
+ registry={direct.registry}
360
+ onSource={direct.changeSource}
361
+ onMode={direct.setOverride}
362
+ onRegistry={direct.setRegistry}
363
+ onRead={() => void direct.loadDetail()}
364
+ onVersion={(version) => void direct.loadDetail(version)}
365
+ onInstall={() =>
366
+ void direct.install(management.requestInstall, (items) =>
367
+ management.setConfirmation({ kind: 'batch', input: { action: 'add', items } })
368
+ )
369
+ }
370
+ onClose={() => direct.setOpen(false)}
371
+ />
372
+ </div>
371
373
  ) : null}
372
374
  <L2PluginInstalledList
373
375
  snapshot={snapshot}
@@ -413,7 +415,13 @@ export function L2PluginManagement({
413
415
  onRefresh={() => void management.refresh()}
414
416
  />
415
417
  {!snapshot?.plugins.length && !direct.open ? (
416
- <Button size="sm" variant="outline" disabled={disabled} onClick={openDirect}>
418
+ <Button
419
+ size="sm"
420
+ variant="outline"
421
+ className="shrink-0 self-start"
422
+ disabled={disabled}
423
+ onClick={openDirect}
424
+ >
417
425
  {t('directInstall')}
418
426
  </Button>
419
427
  ) : null}
@@ -89,8 +89,8 @@ export function L2PluginInstalledList(props: InstalledListProps): React.JSX.Elem
89
89
  label: t(`statusFilter_${value}`)
90
90
  }))
91
91
  return (
92
- <section aria-label={t('installed')} className="min-w-0 space-y-3">
93
- <div className="sticky top-0 z-10 space-y-3 bg-popover pb-2">
92
+ <section aria-label={t('installed')} className="flex min-h-0 min-w-0 flex-col gap-3">
93
+ <div className="shrink-0 space-y-3 bg-popover pb-2">
94
94
  <div className="flex flex-wrap items-center gap-2">
95
95
  <Input
96
96
  aria-label={t('filterInstalled')}
@@ -200,92 +200,96 @@ export function L2PluginInstalledList(props: InstalledListProps): React.JSX.Elem
200
200
  </p>
201
201
  ) : null}
202
202
  </div>
203
- {props.loading && !props.snapshot ? (
204
- <p role="status" className="py-6 text-sm text-muted-foreground">
205
- {t('loading')}
206
- </p>
207
- ) : plugins.length ? (
208
- <div className="divide-y rounded-lg border bg-card">
209
- {plugins.map((plugin) => {
210
- const name = pluginDisplayName(plugin)
211
- const key = pluginRequestKey(plugin.source)
212
- const browserError = plugin.pluginName
213
- ? (props.browserErrors.get(plugin.pluginName) ?? null)
214
- : null
215
- return (
216
- <article
217
- key={key}
218
- aria-label={name}
219
- className={`min-w-0 px-4 py-3 ${props.selected.has(key) && props.batchMode ? 'bg-accent/40' : ''}`}
220
- >
221
- <div className="flex items-start gap-3">
222
- {props.batchMode && plugin.kind !== 'extension' ? (
223
- <div className="flex min-h-8 shrink-0 items-center">
224
- <Checkbox
225
- checked={props.selected.has(key)}
226
- aria-label={t('selectPlugin', { name })}
227
- onCheckedChange={(checked) => props.onSelect([key], checked)}
228
- />
229
- </div>
230
- ) : null}
231
- <div className="min-w-0 flex-1 space-y-1">
232
- <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
233
- <div className="flex min-w-0 flex-1 basis-64 flex-wrap items-center gap-x-2 gap-y-1">
234
- <Button
235
- variant="link"
236
- className="h-auto min-h-8 min-w-0 max-w-full justify-start p-0 text-left text-sm font-semibold whitespace-normal no-underline hover:underline [overflow-wrap:anywhere]"
237
- aria-label={t('viewDetails', { name })}
238
- onClick={() => props.onDetail(plugin.source)}
239
- >
240
- {name}
241
- </Button>
242
- {plugin.version ? (
243
- <span className="text-xs text-muted-foreground">v{plugin.version}</span>
244
- ) : null}
245
- {plugin.updateAvailable ? (
246
- <span className="inline-flex rounded-md bg-status-success/10 px-2 py-0.5 text-xs font-medium text-status-success">
247
- {plugin.availableVersion
248
- ? t('newVersion', { version: plugin.availableVersion })
249
- : t('updateAvailable')}
250
- </span>
251
- ) : null}
252
- {plugin.updateTag && plugin.updateTag !== 'latest' ? (
253
- <span className="text-xs text-muted-foreground">
254
- {plugin.updateTag === 'dev' ? t('channelDev') : plugin.updateTag}
255
- </span>
256
- ) : null}
203
+ <div className="pi-desk-chat-scrollbar min-h-0 overflow-y-auto">
204
+ {props.loading && !props.snapshot ? (
205
+ <p role="status" className="py-6 text-sm text-muted-foreground">
206
+ {t('loading')}
207
+ </p>
208
+ ) : plugins.length ? (
209
+ <div className="divide-y rounded-lg border bg-card">
210
+ {plugins.map((plugin) => {
211
+ const name = pluginDisplayName(plugin)
212
+ const key = pluginRequestKey(plugin.source)
213
+ const browserError = plugin.pluginName
214
+ ? (props.browserErrors.get(plugin.pluginName) ?? null)
215
+ : null
216
+ return (
217
+ <article
218
+ key={key}
219
+ aria-label={name}
220
+ className={`min-w-0 px-4 py-3 ${props.selected.has(key) && props.batchMode ? 'bg-accent/40' : ''}`}
221
+ >
222
+ <div className="flex items-start gap-3">
223
+ {props.batchMode && plugin.kind !== 'extension' ? (
224
+ <div className="flex min-h-8 shrink-0 items-center">
225
+ <Checkbox
226
+ checked={props.selected.has(key)}
227
+ aria-label={t('selectPlugin', { name })}
228
+ onCheckedChange={(checked) => props.onSelect([key], checked)}
229
+ />
230
+ </div>
231
+ ) : null}
232
+ <div className="min-w-0 flex-1 space-y-1">
233
+ <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
234
+ <div className="flex min-w-0 flex-1 basis-64 flex-wrap items-center gap-x-2 gap-y-1">
235
+ <Button
236
+ variant="link"
237
+ className="h-auto min-h-8 min-w-0 max-w-full justify-start p-0 text-left text-sm font-semibold whitespace-normal no-underline hover:underline [overflow-wrap:anywhere]"
238
+ aria-label={t('viewDetails', { name })}
239
+ onClick={() => props.onDetail(plugin.source)}
240
+ >
241
+ {name}
242
+ </Button>
243
+ {plugin.version ? (
244
+ <span className="text-xs text-muted-foreground">v{plugin.version}</span>
245
+ ) : null}
246
+ {plugin.updateAvailable ? (
247
+ <span className="inline-flex rounded-md bg-status-success/10 px-2 py-0.5 text-xs font-medium text-status-success">
248
+ {plugin.availableVersion
249
+ ? t('newVersion', { version: plugin.availableVersion })
250
+ : t('updateAvailable')}
251
+ </span>
252
+ ) : null}
253
+ {plugin.updateTag && plugin.updateTag !== 'latest' ? (
254
+ <span className="text-xs text-muted-foreground">
255
+ {plugin.updateTag === 'dev' ? t('channelDev') : plugin.updateTag}
256
+ </span>
257
+ ) : null}
258
+ </div>
259
+ <L2PluginManagementControls
260
+ plugin={plugin}
261
+ displayName={name}
262
+ browserError={browserError}
263
+ disabled={props.disabled}
264
+ pending={busy(plugin)}
265
+ browserRetryPending={props.requests.has(`entry:${plugin.pluginName}`)}
266
+ onAction={(action) => props.onAction(plugin, action)}
267
+ />
257
268
  </div>
258
- <L2PluginManagementControls
269
+ {plugin.description ? (
270
+ <p className="line-clamp-2 text-sm text-muted-foreground [overflow-wrap:anywhere]">
271
+ {selectL4LocalizedText(plugin.description, locale)}
272
+ </p>
273
+ ) : null}
274
+ <L2PluginManagementFeedback
259
275
  plugin={plugin}
260
- displayName={name}
261
276
  browserError={browserError}
262
- disabled={props.disabled}
263
- pending={busy(plugin)}
264
- browserRetryPending={props.requests.has(`entry:${plugin.pluginName}`)}
265
- onAction={(action) => props.onAction(plugin, action)}
277
+ requestError={
278
+ props.errors[key] ?? props.errors[`entry:${plugin.pluginName}`]
279
+ }
266
280
  />
267
281
  </div>
268
- {plugin.description ? (
269
- <p className="line-clamp-2 text-sm text-muted-foreground [overflow-wrap:anywhere]">
270
- {selectL4LocalizedText(plugin.description, locale)}
271
- </p>
272
- ) : null}
273
- <L2PluginManagementFeedback
274
- plugin={plugin}
275
- browserError={browserError}
276
- requestError={props.errors[key] ?? props.errors[`entry:${plugin.pluginName}`]}
277
- />
278
282
  </div>
279
- </div>
280
- </article>
281
- )
282
- })}
283
- </div>
284
- ) : (
285
- <p className="py-8 text-center text-sm text-muted-foreground">
286
- {t(props.filter || props.statusFilter !== 'all' ? 'noMatches' : 'empty')}
287
- </p>
288
- )}
283
+ </article>
284
+ )
285
+ })}
286
+ </div>
287
+ ) : (
288
+ <p className="py-8 text-center text-sm text-muted-foreground">
289
+ {t(props.filter || props.statusFilter !== 'all' ? 'noMatches' : 'empty')}
290
+ </p>
291
+ )}
292
+ </div>
289
293
  </section>
290
294
  )
291
295
  }
@@ -83,6 +83,11 @@ export function L2WorkbenchChatViewportManager({
83
83
  const positioningRef = useRef(false)
84
84
  const initializedRef = useRef(false)
85
85
  const positionedUserAnchorRef = useRef<string | null>(null)
86
+ // 首屏定位目标跨布局效果重建保留,开始交互或离开视图时释放。
87
+ const entryPositionRef = useRef<{
88
+ anchor: L2WorkbenchChatViewportSnapshot | null
89
+ follow: boolean
90
+ } | null>(null)
86
91
  // 只在首次进入仍在运行的视图时恢复旧锚点;之后的新用户消息使用自己的锚点。
87
92
  const entrySnapshotRef = useRef(running ? entrySnapshot : null)
88
93
  const suspendedViewportRef = useRef<
@@ -109,12 +114,14 @@ export function L2WorkbenchChatViewportManager({
109
114
  if (!active && !running) {
110
115
  initializedRef.current = false
111
116
  entrySnapshotRef.current = null
117
+ entryPositionRef.current = null
112
118
  suspendedViewportRef.current = null
113
119
  stopScroll()
114
120
  logL3ConversationScroll('viewport:leave-ended', scrollElement)
115
121
  return
116
122
  }
117
123
  if (!visible) {
124
+ entryPositionRef.current = null
118
125
  if (initializedRef.current && suspendedViewportRef.current === null) {
119
126
  // stopScroll 会清掉库的跟随状态;查看文件仍只暂停阅读,不恢复跟随。
120
127
  suspendedViewportRef.current =
@@ -171,6 +178,7 @@ export function L2WorkbenchChatViewportManager({
171
178
  if (returnedToPage && visible && !running) {
172
179
  initializedRef.current = false
173
180
  entrySnapshotRef.current = null
181
+ entryPositionRef.current = null
174
182
  }
175
183
  if (!ready || !visible || !pageVisible) return
176
184
  if (!running) onClearSnapshot()
@@ -178,7 +186,8 @@ export function L2WorkbenchChatViewportManager({
178
186
  const initializing = !initializedRef.current
179
187
  const hasNewUser =
180
188
  latestUserAnchor !== null && latestUserAnchor !== positionedUserAnchorRef.current
181
- if (!initializing && !hasNewUser) return
189
+ const entryPosition = hasNewUser ? null : entryPositionRef.current
190
+ if (!initializing && !hasNewUser && !entryPosition) return
182
191
  if (initializing && !running && !containsTail) {
183
192
  onResetToTail()
184
193
  return
@@ -186,6 +195,7 @@ export function L2WorkbenchChatViewportManager({
186
195
 
187
196
  const currentScrollElement = scrollRef.current
188
197
  if (hasNewUser) {
198
+ entryPositionRef.current = null
189
199
  onClearSnapshot()
190
200
  if (currentScrollElement) clearL3ConversationTailSpacer(currentScrollElement)
191
201
  }
@@ -211,7 +221,11 @@ export function L2WorkbenchChatViewportManager({
211
221
 
212
222
  let target: HTMLElement | null = null
213
223
  let targetOffset = L3_CONVERSATION_TOP_GUTTER_PX
214
- const snapshot = initializing && running ? entrySnapshotRef.current : null
224
+ const snapshot = entryPosition
225
+ ? entryPosition.anchor
226
+ : initializing && running
227
+ ? entrySnapshotRef.current
228
+ : null
215
229
  if (snapshot) {
216
230
  const savedTarget = findL3ConversationMessage(
217
231
  scrollElement,
@@ -232,30 +246,31 @@ export function L2WorkbenchChatViewportManager({
232
246
  }
233
247
  target ??= findL3ConversationMessage(scrollElement, 'data-chat-user-message')
234
248
 
235
- const followAfterPosition = target !== null && snapshot === null && (running || !initializing)
236
- const enterRunningAtBottom = initializing && running && snapshot === null
237
- if (target) {
249
+ const followAfterPosition =
250
+ entryPosition?.follow ??
251
+ (target !== null && snapshot === null && (running || !initializing))
252
+ const enterRunningAtBottom =
253
+ entryPosition?.follow ?? (initializing && running && snapshot === null)
254
+ const entryTarget = target
255
+ const positionEntry = (): void => {
238
256
  stopScroll()
239
- positionL3ConversationMessage(scrollElement, target, targetOffset, containsTail)
240
- if (enterRunningAtBottom) {
241
- // 无快照表示应继续吸底;首帧同步置底,后置收敛不再产生可见移动。
257
+ if (entryTarget) {
258
+ positionL3ConversationMessage(scrollElement, entryTarget, targetOffset, containsTail)
259
+ } else {
260
+ clearL3ConversationTailSpacer(scrollElement)
261
+ }
262
+ if (enterRunningAtBottom || !entryTarget) {
263
+ // 首屏懒布局展开后也在绘制前置底,不交给后续缓动收敛。
242
264
  scrollElement.scrollTop = Math.max(
243
265
  0,
244
266
  scrollElement.scrollHeight - scrollElement.clientHeight
245
267
  )
246
268
  }
247
269
  markProgrammaticScroll(state, scrollElement)
248
- } else {
249
- clearL3ConversationTailSpacer(scrollElement)
250
- scrollElement.scrollTop = Math.max(
251
- 0,
252
- scrollElement.scrollHeight - scrollElement.clientHeight
253
- )
254
- void scrollToBottom({ animation: 'instant' })
270
+ // 同步确立跟随,立即切走也不能丢失这个状态。
271
+ if (followAfterPosition || !entryTarget) void scrollToBottom({ animation: 'instant' })
255
272
  }
256
-
257
- // 定位时就确立跟随,不能等下一帧,否则立即切走会丢失这个状态。
258
- if (followAfterPosition) void scrollToBottom({ animation: 'instant' })
273
+ positionEntry()
259
274
 
260
275
  logL3ConversationScroll('viewport:initialize:positioned', scrollElement, {
261
276
  initializing,
@@ -267,21 +282,21 @@ export function L2WorkbenchChatViewportManager({
267
282
  initializedRef.current = true
268
283
  positionedUserAnchorRef.current = latestUserAnchor
269
284
  entrySnapshotRef.current = null
270
- if (initializing && !running && target) {
271
- // 首屏的懒布局仍会改变高度;用户开始操作后立即交还阅读位置。
272
- const entryTarget = target
273
- const observer = new ResizeObserver(() => {
274
- stopScroll()
275
- positionL3ConversationMessage(scrollElement, entryTarget, targetOffset, containsTail)
276
- markProgrammaticScroll(state, scrollElement)
277
- })
285
+ if (initializing || entryPosition) {
286
+ const anchor = target?.dataset.chatScrollAnchor
287
+ entryPositionRef.current = {
288
+ anchor: anchor ? { messageId: anchor, offset: targetOffset } : null,
289
+ follow: followAfterPosition || target === null
290
+ }
291
+ const observer = new ResizeObserver(positionEntry)
278
292
  observer.observe(scrollElement)
279
293
  if (scrollElement.firstElementChild) observer.observe(scrollElement.firstElementChild)
280
294
  const interactionElement = scrollElement.parentElement!
281
295
  const events = ['wheel', 'touchstart', 'pointerdown', 'keydown', 'click'] as const
282
- const release = (): void => {
296
+ const release = (event?: Event): void => {
283
297
  observer.disconnect()
284
298
  for (const event of events) interactionElement.removeEventListener(event, release, true)
299
+ if (event) entryPositionRef.current = null
285
300
  releaseEntryAnchor = null
286
301
  positioningRef.current = false
287
302
  }
@@ -25,10 +25,7 @@ import {
25
25
  type L2PiDirectoryListResponse
26
26
  } from '@common/l2_biz/pi-session/l2-pi-session-contract'
27
27
  import type { L2PluginManagementSnapshot } from '@common/l2_biz/plugin/l2-plugin-management-contract'
28
- import {
29
- L2WorkSessionFilePathSchema,
30
- L2WorkSessionFilePreviewPathSchema
31
- } from '@common/l2_biz/work-session/l2-work-session-file-contract'
28
+ import { L2WorkSessionFilePreviewPathSchema } from '@common/l2_biz/work-session/l2-work-session-file-contract'
32
29
  import type {
33
30
  L2WorkSessionListItem,
34
31
  L2WorkSessionListResponse
@@ -1614,8 +1611,8 @@ export function L2Workbench({
1614
1611
  mode: BrowserFilePreviewMode,
1615
1612
  imagePathsInput?: readonly string[]
1616
1613
  ): void => {
1617
- const path = L2WorkSessionFilePathSchema.safeParse(pathInput)
1618
- const images = L2WorkSessionFilePathSchema.array()
1614
+ const path = L2WorkSessionFilePreviewPathSchema.safeParse(pathInput)
1615
+ const images = L2WorkSessionFilePreviewPathSchema.array()
1619
1616
  .nonempty()
1620
1617
  .optional()
1621
1618
  .safeParse(imagePathsInput)
@@ -1663,7 +1660,7 @@ export function L2Workbench({
1663
1660
  )
1664
1661
  const revealWorkSessionFile = useCallback(
1665
1662
  async (source: PluginSource, pathInput: string): Promise<void> => {
1666
- const path = L2WorkSessionFilePathSchema.safeParse(pathInput)
1663
+ const path = L2WorkSessionFilePreviewPathSchema.safeParse(pathInput)
1667
1664
  if (!path.success) throw new Error(t('invalidFilePath'))
1668
1665
  const current = workSessionsRef.current.find(
1669
1666
  (item) =>
@@ -47,9 +47,16 @@ export type L3ProjectRevealTargetType = z.infer<typeof L3ProjectRevealTargetType
47
47
  export const L3ProjectFileReveal = {
48
48
  path: '/api/project-files/reveal',
49
49
  request: L3ProjectReadContextSchema.extend({
50
- path: L4ProjectDirectoryPathSchema,
50
+ path: z.union([L4ProjectDirectoryPathSchema, L4ProjectFilePreviewPathSchema]),
51
51
  targetType: L3ProjectRevealTargetTypeSchema.optional()
52
- }).refine((input) => input.path !== '' || input.targetType === 'directory', '文件路径不能为空'),
52
+ })
53
+ .refine((input) => input.path !== '' || input.targetType === 'directory', '文件路径不能为空')
54
+ .refine(
55
+ (input) =>
56
+ (input.workId && input.targetType !== 'directory') ||
57
+ L4ProjectDirectoryPathSchema.safeParse(input.path).success,
58
+ '独立项目和目录定位只接受根内相对路径'
59
+ ),
53
60
  response: z.object({}).strict()
54
61
  } as const
55
62
 
@@ -159,8 +159,9 @@ async function resolveProjectFileRevealTarget(
159
159
  root: string,
160
160
  protocolPath: string
161
161
  ): Promise<ProjectFileRevealTarget> {
162
- const unresolved = resolve(root, ...protocolPath.split('/'))
163
- if (!isInsideRoot(root, unresolved)) {
162
+ const absolute = isAbsolute(protocolPath)
163
+ const unresolved = resolve(root, protocolPath)
164
+ if (!absolute && !isInsideRoot(root, unresolved)) {
164
165
  throw new L4WorkSessionProjectFileError(
165
166
  'outside-project',
166
167
  `Project path escapes root: ${protocolPath}`
@@ -176,7 +177,7 @@ async function resolveProjectFileRevealTarget(
176
177
  `Containing directory was not found: ${protocolPath}`
177
178
  )
178
179
  }
179
- if (!isInsideRoot(root, directory)) {
180
+ if (!absolute && !isInsideRoot(root, directory)) {
180
181
  throw new L4WorkSessionProjectFileError(
181
182
  'outside-project',
182
183
  `Containing directory resolves outside root: ${protocolPath}`
@@ -198,7 +199,7 @@ async function resolveProjectFileRevealTarget(
198
199
  }
199
200
  throw new L4WorkSessionProjectFileError('read-failed', `无法读取定位目标:${protocolPath}`)
200
201
  }
201
- if (!isInsideRoot(root, target)) {
202
+ if (!absolute && !isInsideRoot(root, target)) {
202
203
  throw new L4WorkSessionProjectFileError(
203
204
  'outside-project',
204
205
  `Project path resolves outside root: ${protocolPath}`
@@ -1 +1 @@
1
- ZYtCj4W63FjXCrTLTDbvO
1
+ qFbsHgU3sMdDOeAhU56jN
@@ -4,8 +4,8 @@
4
4
  ],
5
5
  "devFiles": [],
6
6
  "lowPriorityFiles": [
7
- "static/ZYtCj4W63FjXCrTLTDbvO/_buildManifest.js",
8
- "static/ZYtCj4W63FjXCrTLTDbvO/_ssgManifest.js"
7
+ "static/qFbsHgU3sMdDOeAhU56jN/_buildManifest.js",
8
+ "static/qFbsHgU3sMdDOeAhU56jN/_ssgManifest.js"
9
9
  ],
10
10
  "rootMainFiles": [
11
11
  "static/chunks/webpack-735430d61da52e9d.js",
@@ -65,8 +65,8 @@
65
65
  "dynamicRoutes": {},
66
66
  "notFoundRoutes": [],
67
67
  "preview": {
68
- "previewModeId": "0a3ad8adf701fa36ecbac47ace976946",
69
- "previewModeSigningKey": "a3f3791a8acb050b62904c994f566bf9c8a80f00258fa87e4d6e77ad59ad72eb",
70
- "previewModeEncryptionKey": "e9355a37cfdf42b64866c1aaa44131f95484c0d1514fb7f35c1bac1630e2987e"
68
+ "previewModeId": "6a3ed0add2f10c8e629c3b4927c3ab47",
69
+ "previewModeSigningKey": "41ebb995a07e123fb88ed9cf02a7c140ce93d47834ee0cb7d9c6c9dcc462cca1",
70
+ "previewModeEncryptionKey": "7ba998796c3a2cb1cef990841e56a970e006ce0833a171001b0266ed108d6aaa"
71
71
  }
72
72
  }
@@ -2,12 +2,12 @@ self.__SERVER_FILES_MANIFEST={
2
2
  "version": 1,
3
3
  "config": {
4
4
  "env": {
5
- "PI_DESK_APP_VERSION": "1.1.10",
6
- "PI_DESK_VERSION_UPDATED_AT": "2026-10-13T10:22:37+08:00"
5
+ "PI_DESK_APP_VERSION": "1.1.12-dev",
6
+ "PI_DESK_VERSION_UPDATED_AT": "2026-10-14T09:06:23+08:00"
7
7
  },
8
8
  "webpack": null,
9
9
  "typescript": {
10
- "ignoreBuildErrors": false
10
+ "ignoreBuildErrors": true
11
11
  },
12
12
  "typedRoutes": false,
13
13
  "distDir": "temp/build/pi-desk/release/.next",
@@ -2,12 +2,12 @@
2
2
  "version": 1,
3
3
  "config": {
4
4
  "env": {
5
- "PI_DESK_APP_VERSION": "1.1.10",
6
- "PI_DESK_VERSION_UPDATED_AT": "2026-10-13T10:22:37+08:00"
5
+ "PI_DESK_APP_VERSION": "1.1.12-dev",
6
+ "PI_DESK_VERSION_UPDATED_AT": "2026-10-14T09:06:23+08:00"
7
7
  },
8
8
  "webpack": null,
9
9
  "typescript": {
10
- "ignoreBuildErrors": false
10
+ "ignoreBuildErrors": true
11
11
  },
12
12
  "typedRoutes": false,
13
13
  "distDir": "temp/build/pi-desk/release/.next",