@jetcrab/pi-desk 1.1.8-dev → 1.1.10

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 (100) hide show
  1. package/docs/plugins.md +15 -1
  2. package/package.json +7 -7
  3. package/src/client/l2_biz/auth/l2-login-biz.ts +4 -1
  4. package/src/client/l2_biz/settings/hooks/l2-use-auth-settings.ts +18 -1
  5. package/src/client/l2_biz/settings/l2-auth-settings-biz.ts +18 -1
  6. package/src/client/l2_biz/settings/l2-auth-settings.tsx +19 -5
  7. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-runtime.ts +144 -23
  8. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-view.tsx +9 -5
  9. package/src/client/l2_biz/workbench/file-workspace/l2-project-file-browser.tsx +94 -5
  10. package/src/client/l2_biz/workbench/file-workspace/l2-project-preview-biz.ts +48 -0
  11. package/src/client/l2_biz/workbench/git-workspace/l2-git-workspace-runtime.ts +26 -8
  12. package/src/client/l2_biz/workbench/hooks/l2-workbench-chat-viewport.ts +70 -47
  13. package/src/client/l2_biz/workbench/l2-work-session-columns.tsx +1 -0
  14. package/src/client/l2_biz/workbench/l2-workbench-chat-view.tsx +18 -14
  15. package/src/client/l2_biz/workbench/l2-workbench.tsx +37 -6
  16. package/src/client/l3_modules/conversation/l3-conversation-messages.tsx +28 -9
  17. package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +27 -15
  18. package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +7 -0
  19. package/src/client/l4_foundation/ui/code/l4-code-preferences.ts +1 -1
  20. package/src/common/l2_biz/auth/l2-auth-contract.ts +14 -1
  21. package/src/common/l2_biz/auth/l2-auth-messages.ts +27 -10
  22. package/src/common/l3_modules/app-runtime/l3-app-runtime-contract.ts +4 -1
  23. package/src/common/l3_modules/conversation/l3-conversation-locale-messages.ts +4 -0
  24. package/src/common/l3_modules/project-files/l3-project-files-locale-messages.ts +8 -0
  25. package/src/common/l4_foundation/file/l4-project-preview-contract.ts +25 -0
  26. package/src/server/l1_entry/api/l1-auth-login-route.ts +20 -6
  27. package/src/server/l1_entry/api/l1-auth-settings-routes.ts +1 -1
  28. package/src/server/l1_entry/api/l1-project-route.ts +14 -5
  29. package/src/server/l1_entry/node/l1-server.ts +4 -7
  30. package/src/server/l2_biz/app-runtime/l2-app-runtime.ts +6 -1
  31. package/src/server/l2_biz/auth/l2-auth.ts +52 -2
  32. package/src/server/l2_biz/work-session/l2-work-session-chat-runtime.ts +28 -0
  33. package/src/server/l2_biz/work-session/l2-work-session-manage.ts +8 -0
  34. package/src/server/l3_modules/app-runtime/l3-app-notification-runtime.ts +48 -14
  35. package/src/server/l4_foundation/auth/l4-auth-config.ts +19 -3
  36. package/src/server/l4_foundation/pi/l4-pi-chat-worker.ts +2 -1
  37. package/src/server/l4_foundation/pi/l4-pi-global-runtime.js +3 -13
  38. package/src/server/l4_foundation/pi/l4-pi-session-plugin-runtime.ts +60 -0
  39. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  40. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  41. package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
  42. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +1 -1
  43. package/temp/build/pi-desk/release/.next/required-server-files.js +3 -3
  44. package/temp/build/pi-desk/release/.next/required-server-files.json +3 -3
  45. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  46. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  47. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  48. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  49. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  50. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  51. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  52. package/temp/build/pi-desk/release/.next/server/app/api/auth/login/route.js +2 -4
  53. package/temp/build/pi-desk/release/.next/server/app/api/auth/logout/route.js +1 -1
  54. package/temp/build/pi-desk/release/.next/server/app/api/auth-settings/get/route.js +1 -1
  55. package/temp/build/pi-desk/release/.next/server/app/api/auth-settings/replace/route.js +1 -1
  56. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  57. package/temp/build/pi-desk/release/.next/server/app/api/project-files/reveal/route.js +1 -1
  58. package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
  59. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  60. package/temp/build/pi-desk/release/.next/server/app/page.js +64 -64
  61. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  62. package/temp/build/pi-desk/release/.next/server/chunks/23054.js +2 -2
  63. package/temp/build/pi-desk/release/.next/server/chunks/25406.js +7 -7
  64. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +3 -3
  65. package/temp/build/pi-desk/release/.next/server/chunks/49900.js +4 -0
  66. package/temp/build/pi-desk/release/.next/server/chunks/5034.js +1 -0
  67. package/temp/build/pi-desk/release/.next/server/chunks/50520.js +1 -1
  68. package/temp/build/pi-desk/release/.next/server/chunks/61211.js +2 -0
  69. package/temp/build/pi-desk/release/.next/server/chunks/6917.js +1 -1
  70. package/temp/build/pi-desk/release/.next/server/chunks/77953.js +1 -0
  71. package/temp/build/pi-desk/release/.next/server/chunks/8584.js +1 -1
  72. package/temp/build/pi-desk/release/.next/server/chunks/92386.js +4 -4
  73. package/temp/build/pi-desk/release/.next/server/chunks/93049.js +1 -0
  74. package/temp/build/pi-desk/release/.next/server/chunks/94296.js +1 -1
  75. package/temp/build/pi-desk/release/.next/server/chunks/98227.js +3 -0
  76. package/temp/build/pi-desk/release/.next/server/instrumentation.js +1 -1
  77. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  78. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  79. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  80. package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
  81. package/temp/build/pi-desk/release/.next/static/chunks/45992-304a49aaaebd853e.js +13 -0
  82. package/temp/build/pi-desk/release/.next/static/chunks/{34903-e4205c0becabea76.js → 47340-c1c09c77c1a88514.js} +1 -1
  83. package/temp/build/pi-desk/release/.next/static/chunks/70669.07f3670bfb7d6dd2.js +1 -0
  84. package/temp/build/pi-desk/release/.next/static/chunks/app/{layout-586f6f88483051a7.js → layout-839162b6cfae8347.js} +1 -1
  85. package/temp/build/pi-desk/release/.next/static/chunks/app/login/page-4532a3bbee48e219.js +1 -0
  86. package/temp/build/pi-desk/release/.next/static/chunks/app/page-9f478658c50efdbe.js +158 -0
  87. package/temp/build/pi-desk/release/.next/static/chunks/{webpack-26261285e181235a.js → webpack-735430d61da52e9d.js} +1 -1
  88. package/temp/build/pi-desk/release/.next/static/css/8a41765636e09e52.css +1 -0
  89. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  90. package/temp/build/pi-desk/release/.next/server/chunks/31442.js +0 -3
  91. package/temp/build/pi-desk/release/.next/server/chunks/37681.js +0 -1
  92. package/temp/build/pi-desk/release/.next/server/chunks/65589.js +0 -4
  93. package/temp/build/pi-desk/release/.next/server/chunks/99672.js +0 -1
  94. package/temp/build/pi-desk/release/.next/static/chunks/45992-a39a014497ec4dd1.js +0 -13
  95. package/temp/build/pi-desk/release/.next/static/chunks/70669.8b4231a8675499e0.js +0 -1
  96. package/temp/build/pi-desk/release/.next/static/chunks/app/login/page-bf893718bb151f3c.js +0 -1
  97. package/temp/build/pi-desk/release/.next/static/chunks/app/page-0f2cf68a4666420b.js +0 -158
  98. package/temp/build/pi-desk/release/.next/static/css/3213ee28336bddc2.css +0 -1
  99. /package/temp/build/pi-desk/release/.next/static/{-pemk30NXpiExEU5tjb1t → ZYtCj4W63FjXCrTLTDbvO}/_buildManifest.js +0 -0
  100. /package/temp/build/pi-desk/release/.next/static/{-pemk30NXpiExEU5tjb1t → ZYtCj4W63FjXCrTLTDbvO}/_ssgManifest.js +0 -0
@@ -58,6 +58,7 @@ interface MutableCwdGitWorkspace {
58
58
  interface ProjectRef {
59
59
  workId: string
60
60
  cwd: string
61
+ readOnly?: boolean
61
62
  }
62
63
 
63
64
  function createWorkspace(cwd: string): MutableCwdGitWorkspace {
@@ -107,7 +108,8 @@ export class L2GitWorkspaceRuntime {
107
108
 
108
109
  reconcileWorkSessions(
109
110
  workSessions: readonly L2WorkSessionListItem[],
110
- focusedWorkId: string | null
111
+ focusedWorkId: string | null,
112
+ previewDirectories: readonly Pick<ProjectRef, 'workId' | 'cwd'>[] = []
111
113
  ): void {
112
114
  const next = new Map<string, ProjectRef>()
113
115
  const focused = focusedWorkId
@@ -120,9 +122,16 @@ export class L2GitWorkspaceRuntime {
120
122
  }
121
123
  }
122
124
 
125
+ for (const directory of previewDirectories) {
126
+ if (!next.has(directory.cwd)) next.set(directory.cwd, { ...directory, readOnly: true })
127
+ }
123
128
  const changed =
124
129
  next.size !== this.projectRefs.size ||
125
- [...next].some(([cwd, ref]) => this.projectRefs.get(cwd)?.workId !== ref.workId)
130
+ [...next].some(
131
+ ([cwd, ref]) =>
132
+ this.projectRefs.get(cwd)?.workId !== ref.workId ||
133
+ this.projectRefs.get(cwd)?.readOnly !== ref.readOnly
134
+ )
126
135
  if (!changed) return
127
136
  this.projectRefs.clear()
128
137
  for (const [cwd, ref] of next) this.projectRefs.set(cwd, ref)
@@ -171,7 +180,7 @@ export class L2GitWorkspaceRuntime {
171
180
  workspace.heads = { ...workspace.heads, status: 'loading', error: null }
172
181
  this.publish()
173
182
  try {
174
- const result = await this.biz.getHeads(project, controller.signal)
183
+ const result = await this.biz.getHeads({ workId: project.workId, cwd }, controller.signal)
175
184
  if (workspace.headsRequest?.token !== token) return
176
185
  workspace.headsRequest = null
177
186
  workspace.heads = { status: 'ready', repositories: result.repositories, error: null }
@@ -238,7 +247,10 @@ export class L2GitWorkspaceRuntime {
238
247
  this.publish()
239
248
 
240
249
  try {
241
- const data = await this.biz.listBranches({ ...project, repositoryRoot }, controller.signal)
250
+ const data = await this.biz.listBranches(
251
+ { workId: project.workId, cwd, repositoryRoot },
252
+ controller.signal
253
+ )
242
254
  if (workspace.branchRequests.get(repositoryRoot)?.token !== token) return
243
255
  workspace.branchRequests.delete(repositoryRoot)
244
256
  workspace.branches.set(repositoryRoot, { status: 'ready', data, error: null })
@@ -261,7 +273,7 @@ export class L2GitWorkspaceRuntime {
261
273
  repositoryRoot: string,
262
274
  target: L2WorkSessionGitBranchTarget
263
275
  ): Promise<void> {
264
- const project = this.requireProject(cwd)
276
+ const project = this.requireWritableProject(cwd)
265
277
  const workspace = this.workspaceFor(cwd)
266
278
  if (workspace.branchOperations.has(repositoryRoot)) return
267
279
  workspace.branchOperations.add(repositoryRoot)
@@ -283,7 +295,7 @@ export class L2GitWorkspaceRuntime {
283
295
  name: string,
284
296
  startPoint: L2WorkSessionGitBranchTarget | null
285
297
  ): Promise<void> {
286
- const project = this.requireProject(cwd)
298
+ const project = this.requireWritableProject(cwd)
287
299
  const workspace = this.workspaceFor(cwd)
288
300
  if (workspace.branchOperations.has(repositoryRoot)) return
289
301
  workspace.branchOperations.add(repositoryRoot)
@@ -346,7 +358,7 @@ export class L2GitWorkspaceRuntime {
346
358
  this.publish()
347
359
 
348
360
  try {
349
- const result = await this.biz.get(project, controller.signal)
361
+ const result = await this.biz.get({ workId: project.workId, cwd }, controller.signal)
350
362
  if (workspace.snapshotRequest?.token !== token) return
351
363
  workspace.snapshotRequest = null
352
364
  workspace.snapshot = { status: 'ready', repositories: result.repositories, error: null }
@@ -373,7 +385,13 @@ export class L2GitWorkspaceRuntime {
373
385
  private requireProject(cwd: string): ProjectRef {
374
386
  const project = this.projectRefs.get(cwd)
375
387
  if (!project) throw new Error(l2WorkbenchText('gitNoProjectOperation'))
376
- return project
388
+ return { workId: project.workId, cwd: project.cwd }
389
+ }
390
+
391
+ private requireWritableProject(cwd: string): ProjectRef {
392
+ if (this.projectRefs.get(cwd)?.readOnly)
393
+ throw new Error(l2WorkbenchText('gitNoProjectOperation'))
394
+ return this.requireProject(cwd)
377
395
  }
378
396
 
379
397
  private applyRepository(
@@ -7,8 +7,7 @@ import {
7
7
  clearL3ConversationTailSpacer,
8
8
  findL3ConversationMessage,
9
9
  logL3ConversationScroll,
10
- positionL3ConversationMessage,
11
- readL3ConversationTailSpacerHeight
10
+ positionL3ConversationMessage
12
11
  } from '@client/l3_modules/conversation/l3-conversation-viewport'
13
12
  import type { L2WorkbenchChatViewportSnapshot } from '../l2-workbench-chat'
14
13
 
@@ -33,6 +32,7 @@ function markProgrammaticScroll(scrollState: StickToBottomState, scrollElement:
33
32
  }
34
33
 
35
34
  interface L2WorkbenchChatViewportManagerProps {
35
+ active: boolean
36
36
  visible?: boolean
37
37
  running: boolean
38
38
  ready: boolean
@@ -48,19 +48,7 @@ function activeTailTurn(scrollElement: HTMLElement): HTMLElement | null {
48
48
  return scrollElement.querySelector<HTMLElement>('[data-chat-tail-space]')
49
49
  }
50
50
 
51
- function captureSnapshot(
52
- scrollElement: HTMLElement,
53
- includeBottom = false
54
- ): L2WorkbenchChatViewportSnapshot | null {
55
- const distanceFromBottom =
56
- scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
57
- if (
58
- !includeBottom &&
59
- readL3ConversationTailSpacerHeight(scrollElement) <= BOTTOM_THRESHOLD_PX &&
60
- distanceFromBottom <= BOTTOM_THRESHOLD_PX
61
- )
62
- return null
63
-
51
+ function captureSnapshot(scrollElement: HTMLElement): L2WorkbenchChatViewportSnapshot | null {
64
52
  const containerTop = scrollElement.getBoundingClientRect().top
65
53
  let closest: { messageId: string; offset: number; distance: number } | null = null
66
54
  for (const message of scrollElement.querySelectorAll<HTMLElement>('[data-chat-scroll-anchor]')) {
@@ -74,6 +62,7 @@ function captureSnapshot(
74
62
  }
75
63
 
76
64
  export function L2WorkbenchChatViewportManager({
65
+ active,
77
66
  visible = true,
78
67
  running,
79
68
  ready,
@@ -96,8 +85,9 @@ export function L2WorkbenchChatViewportManager({
96
85
  const positionedUserAnchorRef = useRef<string | null>(null)
97
86
  // 只在首次进入仍在运行的视图时恢复旧锚点;之后的新用户消息使用自己的锚点。
98
87
  const entrySnapshotRef = useRef(running ? entrySnapshot : null)
99
- const suspendedSnapshotRef = useRef<L2WorkbenchChatViewportSnapshot | null>(null)
100
- const suspendedScrollTopRef = useRef<number | null>(null)
88
+ const suspendedViewportRef = useRef<
89
+ 'follow' | { snapshot: L2WorkbenchChatViewportSnapshot | null; scrollTop: number } | null
90
+ >(null)
101
91
 
102
92
  useLayoutEffect(() => {
103
93
  const scrollElement = scrollRef.current
@@ -115,37 +105,65 @@ export function L2WorkbenchChatViewportManager({
115
105
  useLayoutEffect(() => {
116
106
  const scrollElement = scrollRef.current
117
107
  if (!scrollElement) return
108
+ // 切到其他会话后仍按进入规则定位;同会话查看文件只暂停阅读。
109
+ if (!active && !running) {
110
+ initializedRef.current = false
111
+ entrySnapshotRef.current = null
112
+ suspendedViewportRef.current = null
113
+ stopScroll()
114
+ logL3ConversationScroll('viewport:leave-ended', scrollElement)
115
+ return
116
+ }
118
117
  if (!visible) {
119
- if (suspendedScrollTopRef.current === null) {
120
- suspendedSnapshotRef.current = captureSnapshot(scrollElement, true)
121
- suspendedScrollTopRef.current = scrollElement.scrollTop
118
+ if (initializedRef.current && suspendedViewportRef.current === null) {
119
+ // stopScroll 会清掉库的跟随状态;查看文件仍只暂停阅读,不恢复跟随。
120
+ suspendedViewportRef.current =
121
+ !active && containsTail && state.isAtBottom
122
+ ? 'follow'
123
+ : { snapshot: captureSnapshot(scrollElement), scrollTop: scrollElement.scrollTop }
124
+ logL3ConversationScroll('viewport:suspend', scrollElement, {
125
+ follow: suspendedViewportRef.current === 'follow'
126
+ })
122
127
  }
123
128
  stopScroll()
124
129
  return
125
130
  }
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
131
+ const suspended = suspendedViewportRef.current
132
+ if (suspended === null) return
133
+ if (suspended === 'follow') {
134
+ onClearSnapshot()
135
+ scrollElement.scrollTop = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight)
136
+ markProgrammaticScroll(state, scrollElement)
137
+ void scrollToBottom({ animation: 'instant' })
138
+ logL3ConversationScroll('viewport:restore-follow', scrollElement)
139
+ } else {
140
+ stopScroll()
141
+ const { snapshot } = suspended
142
+ const target = snapshot
143
+ ? findL3ConversationMessage(scrollElement, 'data-chat-scroll-anchor', snapshot.messageId)
144
+ : null
145
+ if (snapshot && target)
146
+ positionL3ConversationMessage(scrollElement, target, snapshot.offset, containsTail)
147
+ else scrollElement.scrollTop = suspended.scrollTop
148
+ markProgrammaticScroll(state, scrollElement)
149
+ logL3ConversationScroll('viewport:restore-reading', scrollElement, { running, snapshot })
134
150
  }
135
- stopScroll()
136
- const snapshot = suspendedSnapshotRef.current
137
- const target = snapshot
138
- ? findL3ConversationMessage(scrollElement, 'data-chat-scroll-anchor', snapshot.messageId)
139
- : null
140
- if (snapshot && target)
141
- positionL3ConversationMessage(scrollElement, target, snapshot.offset, containsTail)
142
- else scrollElement.scrollTop = suspendedScrollTopRef.current
143
- // 仍在运行时,隐藏期间的新消息不能覆盖离开前的阅读位置。
151
+ // 隐藏期间的新消息或回复结束都不能覆盖离开前的阅读位置。
144
152
  initializedRef.current = true
145
153
  positionedUserAnchorRef.current = latestUserAnchor
146
- suspendedSnapshotRef.current = null
147
- suspendedScrollTopRef.current = null
148
- }, [containsTail, latestUserAnchor, running, scrollRef, stopScroll, visible])
154
+ suspendedViewportRef.current = null
155
+ }, [
156
+ active,
157
+ containsTail,
158
+ latestUserAnchor,
159
+ onClearSnapshot,
160
+ running,
161
+ scrollRef,
162
+ scrollToBottom,
163
+ state,
164
+ stopScroll,
165
+ visible
166
+ ])
149
167
 
150
168
  useLayoutEffect(() => {
151
169
  const returnedToPage = pageVisible && !previousPageVisibleRef.current
@@ -225,8 +243,8 @@ export function L2WorkbenchChatViewportManager({
225
243
  0,
226
244
  scrollElement.scrollHeight - scrollElement.clientHeight
227
245
  )
228
- void scrollToBottom({ animation: 'instant' })
229
246
  }
247
+ markProgrammaticScroll(state, scrollElement)
230
248
  } else {
231
249
  clearL3ConversationTailSpacer(scrollElement)
232
250
  scrollElement.scrollTop = Math.max(
@@ -236,6 +254,9 @@ export function L2WorkbenchChatViewportManager({
236
254
  void scrollToBottom({ animation: 'instant' })
237
255
  }
238
256
 
257
+ // 定位时就确立跟随,不能等下一帧,否则立即切走会丢失这个状态。
258
+ if (followAfterPosition) void scrollToBottom({ animation: 'instant' })
259
+
239
260
  logL3ConversationScroll('viewport:initialize:positioned', scrollElement, {
240
261
  initializing,
241
262
  running,
@@ -249,7 +270,6 @@ export function L2WorkbenchChatViewportManager({
249
270
  if (initializing && !running && target) {
250
271
  // 首屏的懒布局仍会改变高度;用户开始操作后立即交还阅读位置。
251
272
  const entryTarget = target
252
- markProgrammaticScroll(state, scrollElement)
253
273
  const observer = new ResizeObserver(() => {
254
274
  stopScroll()
255
275
  positionL3ConversationMessage(scrollElement, entryTarget, targetOffset, containsTail)
@@ -271,14 +291,12 @@ export function L2WorkbenchChatViewportManager({
271
291
  releaseEntryAnchor = release
272
292
  }
273
293
  releaseFrame = window.requestAnimationFrame(() => {
274
- if (followAfterPosition) void scrollToBottom({ animation: 'instant' })
275
- else if (target) stopScroll()
276
294
  positioningRef.current = releaseEntryAnchor !== null
277
295
  })
278
296
  }
279
297
 
280
- // 首次挂载必须在首帧绘制前定位;后续新用户消息仍等待一帧完成布局。
281
- if (initializing && scrollRef.current) position()
298
+ // 已提交的消息在绘制前定位,避免新消息到达后切走取消待执行的定位。
299
+ if (scrollRef.current) position()
282
300
  else positionFrame = window.requestAnimationFrame(position)
283
301
 
284
302
  return () => {
@@ -338,6 +356,11 @@ export function L2WorkbenchChatViewportManager({
338
356
 
339
357
  const save = (): void => {
340
358
  if (positioningRef.current) return
359
+ // 留白是消息定位的布局空间,不代表用户退出了跟随。
360
+ if (containsTail && state.isAtBottom) {
361
+ onClearSnapshot()
362
+ return
363
+ }
341
364
  const snapshot = captureSnapshot(scrollElement)
342
365
  if (snapshot) onSaveSnapshot(snapshot)
343
366
  else onClearSnapshot()
@@ -347,7 +370,7 @@ export function L2WorkbenchChatViewportManager({
347
370
  scrollElement.removeEventListener('scroll', save)
348
371
  save()
349
372
  }
350
- }, [onClearSnapshot, onSaveSnapshot, running, scrollRef, visible])
373
+ }, [containsTail, onClearSnapshot, onSaveSnapshot, running, scrollRef, state, visible])
351
374
 
352
375
  return null
353
376
  }
@@ -329,6 +329,7 @@ const WorkSessionColumn = memo(function WorkSessionColumn({
329
329
  </div>
330
330
  </header>
331
331
  <L2WorkbenchChatView
332
+ active={contentActive}
332
333
  visible={chatVisible && contentActive}
333
334
  workSession={workSession}
334
335
  chatRuntime={chatRuntime}
@@ -25,6 +25,7 @@ import { L2WorkbenchChatNavigation } from './hooks/l2-workbench-chat-navigation'
25
25
  import { L2WorkbenchChatViewportManager } from './hooks/l2-workbench-chat-viewport'
26
26
 
27
27
  interface L2WorkbenchChatViewProps {
28
+ active: boolean
28
29
  visible: boolean
29
30
  workSession: L2WorkSessionListItem
30
31
  chatRuntime: L2WorkbenchChatRuntime
@@ -33,6 +34,7 @@ interface L2WorkbenchChatViewProps {
33
34
  }
34
35
 
35
36
  function L2WorkbenchChatViewComponent({
37
+ active,
36
38
  visible,
37
39
  workSession,
38
40
  chatRuntime,
@@ -138,22 +140,9 @@ function L2WorkbenchChatViewComponent({
138
140
  >
139
141
  {(turnWindow) => (
140
142
  <>
141
- <L2WorkbenchChatViewportManager
142
- key={`viewport:${source.sessionId}:${source.branchId}`}
143
- visible={visible}
144
- running={viewportRunning}
145
- ready={hasMessages}
146
- containsTail={turnWindow.containsTail}
147
- latestUserAnchor={latestUserAnchor}
148
- entrySnapshot={entrySnapshot}
149
- onResetToTail={turnWindow.resetToTail}
150
- onSaveSnapshot={saveViewportSnapshot}
151
- onClearSnapshot={clearViewportSnapshot}
152
- />
153
- <L3ConversationTailSpacerObserver active={visible} />
154
143
  <L3ConversationStickToBottomBridge>
155
144
  <ConversationContent
156
- scrollClassName="pi-desk-chat-scrollbar pi-desk-chat-conversation-scroll"
145
+ scrollClassName="pi-desk-chat-scrollbar pi-desk-chat-conversation-scroll overflow-auto"
157
146
  className="mx-auto min-h-full w-full max-w-3xl gap-6 px-3 pt-3 pb-4 sm:px-5"
158
147
  >
159
148
  {turnWindow.visibleTurns.map((turn, index) => {
@@ -172,6 +161,21 @@ function L2WorkbenchChatViewComponent({
172
161
  })}
173
162
  </ConversationContent>
174
163
  </L3ConversationStickToBottomBridge>
164
+ {/* 内容先挂载,确保视口管理器的布局 effect 能取得滚动节点。 */}
165
+ <L2WorkbenchChatViewportManager
166
+ key={`viewport:${source.sessionId}:${source.branchId}`}
167
+ active={active}
168
+ visible={visible}
169
+ running={viewportRunning}
170
+ ready={hasMessages}
171
+ containsTail={turnWindow.containsTail}
172
+ latestUserAnchor={latestUserAnchor}
173
+ entrySnapshot={entrySnapshot}
174
+ onResetToTail={turnWindow.resetToTail}
175
+ onSaveSnapshot={saveViewportSnapshot}
176
+ onClearSnapshot={clearViewportSnapshot}
177
+ />
178
+ <L3ConversationTailSpacerObserver active={visible} />
175
179
  <L2WorkbenchChatNavigation
176
180
  key={`navigation:${source.sessionId}:${source.branchId}`}
177
181
  active={visible}
@@ -72,6 +72,10 @@ import { createL2WorkbenchBiz } from './l2-workbench-biz'
72
72
  import { createL2WorkbenchVersionRefresh } from './l2-workbench-version-refresh'
73
73
  import { L2FileWorkspaceRuntime } from './file-workspace/l2-file-workspace-runtime'
74
74
  import { L2ProjectFileBrowser } from './file-workspace/l2-project-file-browser'
75
+ import {
76
+ subscribeL2ProjectPreviewDirectories,
77
+ syncL2ProjectPreviewGitReferences
78
+ } from './file-workspace/l2-project-preview-biz'
75
79
  import {
76
80
  L2PluginStandaloneFilePreview,
77
81
  type L2PluginStandaloneFilePreviewTarget
@@ -607,8 +611,8 @@ export function L2Workbench({
607
611
 
608
612
  workSessionsInitializedRef.current = true
609
613
  pluginHost.replaceWorkSessions(next.workSessions)
610
- fileRuntime.reconcileWorkSessions(next.workSessions)
611
614
  workSessionsRef.current = next.workSessions
615
+ fileRuntime.reconcileWorkSessions(next.workSessions)
612
616
  pinnedCountRef.current = next.pinnedCount
613
617
  setPluginStandaloneFilePreview((current) => {
614
618
  if (!current) return null
@@ -811,8 +815,20 @@ export function L2Workbench({
811
815
  }, [])
812
816
 
813
817
  useEffect(() => {
814
- gitRuntime.reconcileWorkSessions(workSessions, focusedWorkId)
815
- }, [focusedWorkId, gitRuntime, workSessions])
818
+ syncL2ProjectPreviewGitReferences(fileRuntime, gitRuntime, workSessions, focusedWorkId)
819
+ }, [fileRuntime, focusedWorkId, gitRuntime, workSessions])
820
+
821
+ useEffect(
822
+ () =>
823
+ subscribeL2ProjectPreviewDirectories(
824
+ chatRuntime,
825
+ fileRuntime,
826
+ gitRuntime,
827
+ () => workSessionsRef.current,
828
+ () => focusedWorkIdRef.current
829
+ ),
830
+ [chatRuntime, fileRuntime, gitRuntime]
831
+ )
816
832
 
817
833
  useEffect(() => {
818
834
  const token = {}
@@ -976,8 +992,22 @@ export function L2Workbench({
976
992
  const displayed = getL2DisplayedWorkSessionIds(layout, pinnedWorkIds)
977
993
  .map((workId) => workSessionById.get(workId))
978
994
  .filter((workSession): workSession is L2WorkSessionListItem => workSession !== undefined)
995
+ if (fileBrowserMounted && activeWorkSession && sidebarMode === 'files') {
996
+ if (!displayed.some((session) => session.workId === activeWorkSession.workId)) {
997
+ displayed.push(activeWorkSession)
998
+ }
999
+ }
979
1000
  chatRuntime.ensureDisplayedWorkSessions(displayed)
980
- }, [chatRuntime, layout, layoutLoaded, pinnedWorkIds, workSessions])
1001
+ }, [
1002
+ activeWorkSession,
1003
+ chatRuntime,
1004
+ fileBrowserMounted,
1005
+ layout,
1006
+ layoutLoaded,
1007
+ pinnedWorkIds,
1008
+ sidebarMode,
1009
+ workSessions
1010
+ ])
981
1011
 
982
1012
  useEffect(() => {
983
1013
  if (!layoutLoaded) return
@@ -1513,7 +1543,7 @@ export function L2Workbench({
1513
1543
  const handleSidebarRefreshFiles = useCallback((): void => {
1514
1544
  if (!activeCwd || !activeWorkId) return
1515
1545
  fileRuntime.refreshTree(activeWorkId)
1516
- void gitRuntime.refreshSnapshot(activeCwd)
1546
+ void gitRuntime.refreshSnapshot(fileRuntime.getDirectory(activeWorkId))
1517
1547
  }, [activeCwd, activeWorkId, fileRuntime, gitRuntime])
1518
1548
  const openGitBranchPicker = useCallback((target: L2GitBranchPickerTarget): void => {
1519
1549
  setGitBranchPickerTarget(target)
@@ -1553,6 +1583,7 @@ export function L2Workbench({
1553
1583
  closeMobileDrawer()
1554
1584
  return
1555
1585
  }
1586
+ fileRuntime.selectDirectory(current.workId, current.cwd)
1556
1587
  fileRuntime.openFile(current.workId, path)
1557
1588
  focusWorkSession(current.workId)
1558
1589
  closeMobileDrawer()
@@ -1851,7 +1882,7 @@ export function L2Workbench({
1851
1882
  if (!activeCwd) return
1852
1883
  closeMobileDrawer()
1853
1884
  fileRuntime.collapseExpandedWindow()
1854
- pluginHost.previewProject(activeCwd)
1885
+ pluginHost.previewProject(activeWorkId ? fileRuntime.getDirectory(activeWorkId) : activeCwd)
1855
1886
  }}
1856
1887
  onOpenTerminal={() => {
1857
1888
  closeMobileDrawer()
@@ -91,6 +91,7 @@ interface DetailLoadState {
91
91
  }
92
92
 
93
93
  const USER_MESSAGE_COLLAPSE_THRESHOLD_LINES = 6
94
+ const THINKING_RENDER_CHARACTER_LIMIT = 5_000
94
95
  const MESSAGE_COPY_FEEDBACK_MS = 1500
95
96
  const PROCESS_ITEM_BATCH_SIZE = 64
96
97
  const TOOL_DETAIL_SCROLL_CLASS = 'max-h-[min(20rem,45dvh)] overflow-auto [scrollbar-gutter:stable]'
@@ -656,22 +657,40 @@ function AssistantReasoningContent({
656
657
  content: string
657
658
  streaming: boolean
658
659
  }): React.JSX.Element {
660
+ const { t } = useTranslation('conversation')
659
661
  const { scrollRef, contentRef } = useStickToBottom({
660
662
  initial: streaming ? 'instant' : false,
661
663
  resize: 'instant'
662
664
  })
665
+ const truncated = content.length > THINKING_RENDER_CHARACTER_LIMIT
666
+ const visibleContent = content.slice(-THINKING_RENDER_CHARACTER_LIMIT)
663
667
 
664
668
  return (
665
- <div
666
- ref={scrollRef}
667
- className="max-h-56 overflow-y-auto pb-2 pr-2 [scrollbar-gutter:stable] sm:max-h-72"
668
- >
669
- <div ref={contentRef}>
670
- <L3ConversationMarkdown isAnimating={streaming} mode={streaming ? 'streaming' : 'static'}>
671
- {content}
672
- </L3ConversationMarkdown>
669
+ <>
670
+ <div
671
+ ref={scrollRef}
672
+ className="max-h-56 overflow-y-auto pb-2 pr-2 [scrollbar-gutter:stable] sm:max-h-72"
673
+ >
674
+ <div ref={contentRef}>
675
+ {/* Streamdown 按节点位置缓存元素;窗口移动时重建,避免等长内容沿用旧文本。 */}
676
+ <L3ConversationMarkdown
677
+ key={truncated ? content.length : undefined}
678
+ isAnimating={streaming}
679
+ mode={streaming ? 'streaming' : 'static'}
680
+ >
681
+ {visibleContent}
682
+ </L3ConversationMarkdown>
683
+ </div>
673
684
  </div>
674
- </div>
685
+ {truncated ? (
686
+ <div className="flex items-center justify-between gap-2 pt-1">
687
+ <p className="text-xs text-muted-foreground">
688
+ {t('thinkingTruncated', { count: THINKING_RENDER_CHARACTER_LIMIT })}
689
+ </p>
690
+ <MessageCopyAction text={content} copyLabel={t('copyThinking')} alwaysVisible />
691
+ </div>
692
+ ) : null}
693
+ </>
675
694
  )
676
695
  }
677
696
 
@@ -64,6 +64,8 @@ interface L3ProjectFileBrowserProps {
64
64
  gitRuntime: L3ProjectGitBrowser
65
65
  onOpenGitBranches: (repositoryRoot: string) => void
66
66
  onFileOpened?: () => void
67
+ rootTitle?: React.ReactNode
68
+ gitReadOnly?: boolean
67
69
  }
68
70
 
69
71
  const EMPTY_GIT_REPOSITORIES: readonly L4GitRepository[] = []
@@ -155,7 +157,9 @@ export function L3ProjectFileBrowser({
155
157
  runtime,
156
158
  gitRuntime,
157
159
  onOpenGitBranches,
158
- onFileOpened
160
+ onFileOpened,
161
+ rootTitle,
162
+ gitReadOnly = false
159
163
  }: L3ProjectFileBrowserProps): React.JSX.Element {
160
164
  const { t } = useTranslation('projectFiles')
161
165
  const { locale } = useL4Region()
@@ -165,7 +169,8 @@ export function L3ProjectFileBrowser({
165
169
  const gitWorkspace = gitRuntime.getWorkspace(cwd)
166
170
  const scrollRef = useRef<HTMLDivElement>(null)
167
171
  const searchInputRef = useRef<HTMLInputElement>(null)
168
- const [searchOpen, setSearchOpen] = useState(false)
172
+ const searchOpen = workspace.searchOpen
173
+ const setSearchOpen = (open: boolean): void => runtime.setSearchOpen(open)
169
174
  const [visibleEntries, setVisibleEntries] = useState<Record<string, number>>({})
170
175
  const moreEntries = (key: string, total: number): React.ReactNode => {
171
176
  const count = visibleEntries[key] ?? 300
@@ -548,14 +553,14 @@ export function L3ProjectFileBrowser({
548
553
  <button
549
554
  data-testid={`project-git-branch-${root || 'root'}`}
550
555
  type="button"
551
- disabled={repository.state !== 'ready'}
556
+ disabled={gitReadOnly || repository.state !== 'ready'}
552
557
  className={cn(
553
558
  buttonVariants({ variant: 'outline', size: 'sm' }),
554
559
  'max-w-28 shrink gap-1 font-mono text-xs text-muted-foreground'
555
560
  )}
556
561
  title={
557
562
  repository.state === 'ready'
558
- ? t('gitBranches')
563
+ ? t(gitReadOnly ? 'gitBranchReadOnly' : 'gitBranches')
559
564
  : selectL4LocalizedText(repository.message, locale)
560
565
  }
561
566
  onClick={() => onOpenGitBranches(root)}
@@ -667,28 +672,35 @@ export function L3ProjectFileBrowser({
667
672
  runtime.canReveal ? (path, type) => runtime.revealFile(path, type) : undefined
668
673
  }
669
674
  trigger={
670
- <div tabIndex={0} className="flex min-w-0 flex-1 items-baseline gap-1">
671
- <p className="shrink-0 truncate text-xs font-semibold" title={projectName}>
672
- {projectName}
673
- </p>
674
- <span className="text-xs text-muted-foreground">·</span>
675
- <p className="min-w-0 truncate font-mono text-xs text-muted-foreground" title={cwd}>
676
- {compactPath(cwd)}
677
- </p>
678
- </div>
675
+ rootTitle ? (
676
+ <div className="flex min-w-0 flex-1">{rootTitle}</div>
677
+ ) : (
678
+ <div tabIndex={0} className="flex min-w-0 flex-1 items-baseline gap-1">
679
+ <p className="shrink-0 truncate text-xs font-semibold" title={projectName}>
680
+ {projectName}
681
+ </p>
682
+ <span className="text-xs text-muted-foreground">·</span>
683
+ <p
684
+ className="min-w-0 truncate font-mono text-xs text-muted-foreground"
685
+ title={cwd}
686
+ >
687
+ {compactPath(cwd)}
688
+ </p>
689
+ </div>
690
+ )
679
691
  }
680
692
  />
681
693
  {singleProjectRootRepository ? (
682
694
  <button
683
695
  type="button"
684
- disabled={singleProjectRootRepository.state !== 'ready'}
696
+ disabled={gitReadOnly || singleProjectRootRepository.state !== 'ready'}
685
697
  className={cn(
686
698
  buttonVariants({ variant: 'outline', size: 'sm' }),
687
699
  'max-w-32 shrink gap-1 font-mono text-xs text-muted-foreground'
688
700
  )}
689
701
  title={
690
702
  singleProjectRootRepository.state === 'ready'
691
- ? t('gitBranches')
703
+ ? t(gitReadOnly ? 'gitBranchReadOnly' : 'gitBranches')
692
704
  : selectL4LocalizedText(singleProjectRootRepository.message, locale)
693
705
  }
694
706
  onClick={() => onOpenGitBranches('')}
@@ -79,6 +79,7 @@ export interface L3ProjectFileState {
79
79
  expandedPaths: Set<string>
80
80
  treeScrollTop: number
81
81
  search: L3FileSearch
82
+ searchOpen: boolean
82
83
  documents: Map<string, L3FileDocument>
83
84
  activeDiff: L3FileDiff | null
84
85
  }
@@ -130,6 +131,7 @@ export class L3ProjectFileRuntime {
130
131
  expandedPaths: new Set(),
131
132
  treeScrollTop: 0,
132
133
  search: { query: '', status: 'idle', matches: [], truncated: false, error: null },
134
+ searchOpen: false,
133
135
  documents: new Map(),
134
136
  activeDiff: null
135
137
  }
@@ -159,6 +161,11 @@ export class L3ProjectFileRuntime {
159
161
  return () => this.listeners.delete(listener)
160
162
  }
161
163
  getRevision = (): number => this.revision
164
+ setSearchOpen(open: boolean): void {
165
+ if (this.state.searchOpen === open) return
166
+ this.state.searchOpen = open
167
+ this.publish()
168
+ }
162
169
  get canReveal(): boolean {
163
170
  return Boolean(this.options.reveal)
164
171
  }
@@ -8,7 +8,7 @@ const LEGACY_STORAGE_KEY = 'pi-super:project-preview:global'
8
8
  const PreferencesSchema = z.object({
9
9
  wrapLines: z.boolean().default(true),
10
10
  diffViewMode: z.enum(['side-by-side', 'inline']).default('side-by-side'),
11
- collapseUnchanged: z.boolean().default(true)
11
+ collapseUnchanged: z.boolean().default(false)
12
12
  })
13
13
  type CodePreferences = z.infer<typeof PreferencesSchema>
14
14
  const DEFAULT_PREFERENCES = PreferencesSchema.parse({})