@jetcrab/pi-desk 1.1.8 → 1.1.10-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 (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-ca3020d12ae98f64.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-c92ecc3b7351294d.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/{ZYYO9lnf3gpocPCjcjYmq → d7n5VhA6J-kI3A_-XJ4TQ}/_buildManifest.js +0 -0
  100. /package/temp/build/pi-desk/release/.next/static/{ZYYO9lnf3gpocPCjcjYmq → d7n5VhA6J-kI3A_-XJ4TQ}/_ssgManifest.js +0 -0
package/docs/plugins.md CHANGED
@@ -67,6 +67,18 @@ plugin.push('changed', {})
67
67
 
68
68
  Node `plugin.setState()` 则整体替换自身全局状态;Browser 通过 `host.globalState.getSnapshot()/subscribe()` 只读。两者不是同一槽位,均不得读写其他插件状态。宿主地区设置由 `host.settings.getSnapshot()/subscribe()` 只读,不另存权威副本。
69
69
 
70
+ ### 会话预览目录
71
+
72
+ Session 插件可为左侧项目文件增加目录,不改变 Pi 会话 CWD:
73
+
74
+ ```ts
75
+ plugin.addPreviewDirectory(directory, { default: true })
76
+ ```
77
+
78
+ `directory` 为服务端绝对目录;省略第二个参数只登记,`default: true` 同时设为默认预览目录,后登记的默认生效。原 CWD 始终可选;目录规范化后去重,最多登记 32 个。重复快照不会覆盖用户的手动选择。登记只在当前会话运行态内,插件在 `session_start`、`session_tree` 从自身数据恢复后重新登记;不要在普通查询时重复请求默认选择。
79
+
80
+ 调用不等待回执;非法路径或超限由宿主拒绝并记日志,实际目录内容按需读取。旧宿主或独立 Pi 不处理该登记;兼容旧 SDK 时使用 `plugin.addPreviewDirectory?.(...)`。已有 `files.preview/reveal/readImage` 的相对路径仍以会话 CWD 为基准。
81
+
70
82
  ## Browser Entry 与懒界面
71
83
 
72
84
  Entry 默认导出普通同步函数即可;在函数返回前完成全部 `registerContribution(kind, name, definition)`。kind 支持 `application`、`composer-panel`、`settings-page`、`session-sidebar-tab`、`message-view`。Entry 只放元数据、loader、轻量页面状态和事件订阅;返回 disposer 清理自有订阅与资源。
@@ -110,7 +122,9 @@ Method 调用没有 Browser 视图级取消参数;Global handler 的 `context.
110
122
  Push 可丢失、不持久化、不 ACK、不重放,也不创建聊天消息。Entry ready、连接恢复 ready、视图 mount 时按需要查询 Method 建立权威基线;不要用页面轮询替代已有的宿主状态同步。
111
123
 
112
124
  - 当前页面轻提示:`plugin.notify({ level: 'success', title: '保存成功' })` 或 `host.notify()`,只在当前页面短暂展示,离线可错过。
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 重启或插件释放后清空,不持久化。
125
+ - 服务端全局通知:Node `plugin.notifications.publish({ key: 'result:run-1', level: 'success', title: '处理完成', event: { name: 'open-result', data: { runId: 'run-1' } } })`;用返回 ID 调用 `update/delete`。Browser Entry 通过 `plugin.notifications.onEvent('open-result', handler)` 处理点击,成功后宿主删除通知。Node 未重启时重连可恢复;Node 重启或插件释放后清空,不持久化。
126
+
127
+ `publish()` 的 `key` 可选,去除首尾空白后为 1~256 字符,由业务标识事件,例如 `post:${postId}` 或 `build:${runId}`。同一插件、同一 `key` 的现存通知只保留一条:内容相同不广播;内容变化整体更新文字、级别和点击 Event,返回原 ID,保留创建时间与位置。再次发布时省略 `description/event` 会清除旧值;`update()` 仍只修改文字和级别。不传 `key` 每次新增,不按文字自动合并。通知删除或淘汰后同 `key` 可重新创建,不保存已关闭记录;`key` 只存在于服务端通知内存,不随通知传给浏览器。带 `key` 的发布需使用支持通知去重的宿主;旧宿主会拒绝未知字段,不自动降级为新增通知。
114
128
 
115
129
  ## 消息展示
116
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jetcrab/pi-desk",
3
- "version": "1.1.8",
3
+ "version": "1.1.10-dev",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -69,13 +69,13 @@
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.0"
72
+ "@jetcrab/pi-desk-sdk": "^1.1.2-dev"
73
73
  },
74
74
  "devDependencies": {
75
- "@earendil-works/pi-agent-core": "1.0.1",
76
- "@earendil-works/pi-ai": "1.0.1",
77
- "@earendil-works/pi-coding-agent": "1.0.1",
78
- "@earendil-works/pi-tui": "1.0.1",
75
+ "@earendil-works/pi-agent-core": "1.2.0",
76
+ "@earendil-works/pi-ai": "1.2.0",
77
+ "@earendil-works/pi-coding-agent": "1.2.0",
78
+ "@earendil-works/pi-tui": "1.2.0",
79
79
  "@tailwindcss/postcss": "^4",
80
80
  "@types/node": "^20",
81
81
  "@types/react": "^19",
@@ -94,7 +94,7 @@
94
94
  "yaml": "^2.9.0"
95
95
  },
96
96
  "peerDependencies": {
97
- "@earendil-works/pi-coding-agent": ">=1.0.1"
97
+ "@earendil-works/pi-coding-agent": "*"
98
98
  },
99
99
  "peerDependenciesMeta": {
100
100
  "@earendil-works/pi-coding-agent": {
@@ -11,7 +11,10 @@ export async function loginL2Auth(clientId: string, input: L2AuthLoginRequest):
11
11
  const parsed = L2AuthLoginRequestSchema.safeParse(input)
12
12
  if (!parsed.success) {
13
13
  throw new Error(
14
- l4LocalizedErrorMessage({ msg: '登录参数无效', i18n: { key: 'auth:invalidInput' } })
14
+ l4LocalizedErrorMessage({
15
+ msg: '登录失败,请检查登录信息后重试。',
16
+ i18n: { key: 'auth:invalidInput' }
17
+ })
15
18
  )
16
19
  }
17
20
  const response = await fetch('/api/auth/login', {
@@ -5,7 +5,12 @@ import { useTranslation } from 'react-i18next'
5
5
  import type { L2AuthSettings } from '@common/l2_biz/auth/l2-auth-contract'
6
6
  import { useL4AppSocket } from '@client/l4_foundation/realtime/app-socket/l4-app-socket'
7
7
  import { useL4ConfirmDialog } from '@client/l4_foundation/ui/l4-confirm-dialog'
8
- import { getL2AuthSettings, logoutL2Auth, replaceL2AuthSettings } from '../l2-auth-settings-biz'
8
+ import {
9
+ generateL2AuthPassword,
10
+ getL2AuthSettings,
11
+ logoutL2Auth,
12
+ replaceL2AuthSettings
13
+ } from '../l2-auth-settings-biz'
9
14
 
10
15
  interface AuthSettingsState {
11
16
  settings: L2AuthSettings | null
@@ -17,6 +22,7 @@ interface AuthSettingsState {
17
22
  setPassword: (value: string) => void
18
23
  retry: () => void
19
24
  save: () => Promise<void>
25
+ generatePassword: () => void
20
26
  disable: () => Promise<void>
21
27
  logout: () => Promise<void>
22
28
  dialog: React.JSX.Element | null
@@ -51,6 +57,16 @@ export function useL2AuthSettings(): AuthSettingsState {
51
57
  return () => controller.abort()
52
58
  }, [clientId, attempt, t])
53
59
 
60
+ const generatePassword = (): void => {
61
+ if (pending.current) return
62
+ try {
63
+ setPassword(generateL2AuthPassword())
64
+ setError(null)
65
+ } catch {
66
+ setError(t('generatePasswordFailed'))
67
+ }
68
+ }
69
+
54
70
  const save = async (): Promise<void> => {
55
71
  if (pending.current || !settings) return
56
72
  pending.current = true
@@ -111,6 +127,7 @@ export function useL2AuthSettings(): AuthSettingsState {
111
127
  password,
112
128
  setPassword,
113
129
  save,
130
+ generatePassword,
114
131
  disable,
115
132
  logout,
116
133
  dialog,
@@ -47,6 +47,23 @@ export async function logoutL2Auth(clientId: string): Promise<void> {
47
47
  window.location.replace('/login')
48
48
  }
49
49
 
50
+ export function generateL2AuthPassword(): string {
51
+ // 64 个字符可直接映射随机字节,不产生取模偏差。
52
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@'
53
+ while (true) {
54
+ const bytes = crypto.getRandomValues(new Uint8Array(16))
55
+ const password = Array.from(bytes, (value) => alphabet[value % 64]).join('')
56
+ if (
57
+ /[A-Z]/.test(password) &&
58
+ /[a-z]/.test(password) &&
59
+ /[0-9]/.test(password) &&
60
+ /[!@]/.test(password)
61
+ ) {
62
+ return password
63
+ }
64
+ }
65
+ }
66
+
50
67
  export async function replaceL2AuthSettings(
51
68
  clientId: string,
52
69
  input: L2AuthSettingsReplaceRequest
@@ -55,7 +72,7 @@ export async function replaceL2AuthSettings(
55
72
  if (!parsed.success) {
56
73
  throw new Error(
57
74
  l4LocalizedErrorMessage({
58
- msg: '请填写至少 8 位密码',
75
+ msg: '密码不符合要求,请按输入框下方的规则调整。',
59
76
  i18n: { key: 'auth:settingsInvalid' }
60
77
  })
61
78
  )
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  ChevronRightIcon,
5
+ DicesIcon,
5
6
  LoaderCircleIcon,
6
7
  LogOutIcon,
7
8
  ShieldCheckIcon,
@@ -76,15 +77,28 @@ export function L2AuthSettings(): React.JSX.Element {
76
77
  </div>
77
78
  <div className="min-w-0 max-w-[40rem] space-y-4">
78
79
  <div className="space-y-1">
79
- <label className="block text-sm font-medium" htmlFor="auth-password">
80
- {t(enabled ? 'newPassword' : 'password')}
81
- </label>
80
+ <div className="flex flex-wrap items-center justify-between gap-2">
81
+ <label className="block text-sm font-medium" htmlFor="auth-password">
82
+ {t(enabled ? 'newPassword' : 'password')}
83
+ </label>
84
+ <Button
85
+ type="button"
86
+ variant="ghost"
87
+ size="sm"
88
+ disabled={busy}
89
+ title={t('generatePasswordHint')}
90
+ onClick={state.generatePassword}
91
+ >
92
+ <DicesIcon aria-hidden="true" />
93
+ {t('generatePassword')}
94
+ </Button>
95
+ </div>
82
96
  <L4PasswordInput
83
97
  id="auth-password"
84
98
  name="password"
85
99
  className="[&::-ms-reveal]:hidden"
86
100
  autoComplete="new-password"
87
- minLength={8}
101
+ minLength={12}
88
102
  maxLength={256}
89
103
  required
90
104
  disabled={busy}
@@ -92,7 +106,7 @@ export function L2AuthSettings(): React.JSX.Element {
92
106
  aria-describedby={`auth-password-hint${state.error ? ' auth-settings-error' : ''}`}
93
107
  onChange={(event) => state.setPassword(event.target.value)}
94
108
  />
95
- <p id="auth-password-hint" className="text-xs text-muted-foreground">
109
+ <p id="auth-password-hint" className="text-sm text-muted-foreground">
96
110
  {t('passwordHint')}
97
111
  </p>
98
112
  </div>
@@ -1,4 +1,9 @@
1
1
  import type { L2WorkSessionListItem } from '@common/l2_biz/work-session/l2-work-session-contract'
2
+ import type { L2ChatSource } from '@common/l2_biz/chat/l2-chat-contract'
3
+ import {
4
+ projectPreviewDirectoryKey,
5
+ type L4ProjectPreviewState
6
+ } from '@common/l4_foundation/file/l4-project-preview-contract'
2
7
  import type { L4CodePreviewState } from '@client/l4_foundation/ui/code/l4-code-preview'
3
8
  import { readL3ImagePreviewMode } from '@client/l3_modules/preferences/l3-work-session-file-preferences'
4
9
  import {
@@ -26,12 +31,21 @@ export interface L2SessionFileWorkspace extends L3ProjectFileState {
26
31
  readonly activePane: 'chat' | 'files'
27
32
  }
28
33
  type SourceRef = Pick<L2WorkSessionListItem, 'workId' | 'cwd' | 'sessionId' | 'branchId'>
34
+ interface FileRoot {
35
+ files: L3ProjectFileRuntime
36
+ unsubscribe: () => void
37
+ }
38
+ interface SessionDirectories {
39
+ directories: string[]
40
+ defaultDirectory: string | null
41
+ selectedDirectory: string
42
+ }
29
43
  interface SessionLayout {
30
44
  files: L3ProjectFileRuntime
45
+ roots: Map<string, FileRoot>
31
46
  windowOpen: boolean
32
47
  chatWidthPercent: number
33
48
  activePane: 'chat' | 'files'
34
- unsubscribe: () => void
35
49
  }
36
50
 
37
51
  export class L2FileWorkspaceRuntime {
@@ -39,6 +53,7 @@ export class L2FileWorkspaceRuntime {
39
53
  private readonly listeners = new Set<() => void>()
40
54
  private readonly workspaces = new Map<string, SessionLayout>()
41
55
  private readonly sources = new Map<string, SourceRef>()
56
+ private readonly previewDirectories = new Map<string, SessionDirectories>()
42
57
  private expandedWorkId: string | null = null
43
58
  private revision = 0
44
59
  constructor(
@@ -69,6 +84,69 @@ export class L2FileWorkspaceRuntime {
69
84
  getExpandedWorkId(): string | null {
70
85
  return this.expandedWorkId
71
86
  }
87
+ getDirectories(workId: string): readonly string[] {
88
+ return this.previewDirectories.get(workId)?.directories ?? []
89
+ }
90
+ getDirectory(workId: string): string {
91
+ const selection = this.previewDirectories.get(workId)
92
+ if (!selection) throw new Error(l2WorkbenchText('sourceChanged'))
93
+ return selection.selectedDirectory
94
+ }
95
+ applyPreviewDirectories(source: L2ChatSource, state: L4ProjectPreviewState): void {
96
+ const current = this.sources.get(source.workId)
97
+ if (!current || current.sessionId !== source.sessionId || current.branchId !== source.branchId)
98
+ return
99
+ const previous = this.previewDirectories.get(source.workId)!
100
+ const unique = new Map([[projectPreviewDirectoryKey(current.cwd), current.cwd]])
101
+ for (const directory of state.directories) {
102
+ const key = projectPreviewDirectoryKey(directory)
103
+ if (!unique.has(key)) unique.set(key, directory)
104
+ }
105
+ const defaultDirectory = state.defaultDirectory
106
+ ? (unique.get(projectPreviewDirectoryKey(state.defaultDirectory)) ?? null)
107
+ : null
108
+ const previousDefaultKey = previous.defaultDirectory
109
+ ? projectPreviewDirectoryKey(previous.defaultDirectory)
110
+ : null
111
+ const defaultKey = defaultDirectory ? projectPreviewDirectoryKey(defaultDirectory) : null
112
+ const selectedDirectory =
113
+ previousDefaultKey !== defaultKey
114
+ ? (defaultDirectory ?? current.cwd)
115
+ : (unique.get(projectPreviewDirectoryKey(previous.selectedDirectory)) ?? current.cwd)
116
+ const directories = [...unique.values()]
117
+ if (
118
+ previous.defaultDirectory === defaultDirectory &&
119
+ previous.selectedDirectory === selectedDirectory &&
120
+ previous.directories.length === directories.length &&
121
+ previous.directories.every((directory, index) => directory === directories[index])
122
+ )
123
+ return
124
+ this.previewDirectories.set(source.workId, { directories, defaultDirectory, selectedDirectory })
125
+ const layout = this.workspaces.get(source.workId)
126
+ if (layout) {
127
+ for (const [key, root] of layout.roots) {
128
+ if (unique.has(key)) continue
129
+ root.unsubscribe()
130
+ root.files.dispose()
131
+ layout.roots.delete(key)
132
+ }
133
+ this.switchFiles(source.workId, layout)
134
+ }
135
+ this.publish()
136
+ }
137
+ selectDirectory(workId: string, directory: string): void {
138
+ const selection = this.previewDirectories.get(workId)
139
+ if (!selection) throw new Error(l2WorkbenchText('sourceChanged'))
140
+ const registered = selection.directories.find(
141
+ (candidate) => projectPreviewDirectoryKey(candidate) === projectPreviewDirectoryKey(directory)
142
+ )
143
+ if (!registered) throw new Error(l2WorkbenchText('sourceChanged'))
144
+ if (selection.selectedDirectory === registered) return
145
+ selection.selectedDirectory = registered
146
+ const layout = this.workspaces.get(workId)
147
+ if (layout) this.switchFiles(workId, layout)
148
+ this.publish()
149
+ }
72
150
  reconcileWorkSessions(sessions: readonly L2WorkSessionListItem[]): void {
73
151
  this.standaloneImages.reconcileWorkSessions(sessions)
74
152
  const next = new Map(
@@ -88,15 +166,24 @@ export class L2FileWorkspaceRuntime {
88
166
  continue
89
167
  const layout = this.workspaces.get(id)
90
168
  if (layout) {
91
- layout.unsubscribe()
92
- layout.files.dispose()
169
+ this.disposeLayout(layout)
93
170
  this.workspaces.delete(id)
94
- changed = true
95
171
  }
172
+ this.previewDirectories.delete(id)
173
+ changed = true
96
174
  if (this.expandedWorkId === id) this.expandedWorkId = null
97
175
  }
98
176
  this.sources.clear()
99
- for (const [id, source] of next) this.sources.set(id, source)
177
+ for (const [id, source] of next) {
178
+ this.sources.set(id, source)
179
+ if (!this.previewDirectories.has(id)) {
180
+ this.previewDirectories.set(id, {
181
+ directories: [source.cwd],
182
+ defaultDirectory: null,
183
+ selectedDirectory: source.cwd
184
+ })
185
+ }
186
+ }
100
187
  if (changed) this.publish()
101
188
  }
102
189
  ensureRoot(id: string): Promise<void> {
@@ -214,7 +301,9 @@ export class L2FileWorkspaceRuntime {
214
301
  }
215
302
  handleGitBranchChanged(cwd: string, root: string): void {
216
303
  for (const layout of this.workspaces.values()) {
217
- if (layout.files.cwd === cwd) layout.files.handleGitBranchChanged(root)
304
+ for (const entry of layout.roots.values()) {
305
+ if (entry.files.cwd === cwd) entry.files.handleGitBranchChanged(root)
306
+ }
218
307
  }
219
308
  }
220
309
  saveEditorState(id: string, path: string, state: L4CodePreviewState | null): void {
@@ -228,12 +317,10 @@ export class L2FileWorkspaceRuntime {
228
317
  }
229
318
  dispose(): void {
230
319
  this.standaloneImages.dispose()
231
- for (const layout of this.workspaces.values()) {
232
- layout.unsubscribe()
233
- layout.files.dispose()
234
- }
320
+ for (const layout of this.workspaces.values()) this.disposeLayout(layout)
235
321
  this.workspaces.clear()
236
322
  this.sources.clear()
323
+ this.previewDirectories.clear()
237
324
  this.expandedWorkId = null
238
325
  this.listeners.clear()
239
326
  }
@@ -242,9 +329,21 @@ export class L2FileWorkspaceRuntime {
242
329
  if (!source) throw new Error(l2WorkbenchText('sourceChanged'))
243
330
  const existing = this.workspaces.get(id)
244
331
  if (existing) return existing
245
- const context = { workId: id, cwd: source.cwd }
246
- const files = new L3ProjectFileRuntime(
247
- source.cwd,
332
+ const layout: SessionLayout = {
333
+ files: this.createFiles(id, this.getDirectory(id)),
334
+ roots: new Map(),
335
+ windowOpen: false,
336
+ chatWidthPercent: 50,
337
+ activePane: 'files'
338
+ }
339
+ this.attachRoot(id, layout, layout.files)
340
+ this.workspaces.set(id, layout)
341
+ return layout
342
+ }
343
+ private createFiles(id: string, cwd: string): L3ProjectFileRuntime {
344
+ const context = { workId: id, cwd }
345
+ return new L3ProjectFileRuntime(
346
+ cwd,
248
347
  {
249
348
  list: (path, signal) => this.biz.list({ ...context, path }, signal),
250
349
  search: (query, signal) => this.biz.search({ ...context, query }, signal),
@@ -266,22 +365,44 @@ export class L2FileWorkspaceRuntime {
266
365
  }
267
366
  }
268
367
  )
269
- const layout: SessionLayout = {
270
- files,
271
- windowOpen: false,
272
- chatWidthPercent: 50,
273
- activePane: 'files',
274
- unsubscribe: () => undefined
275
- }
276
- layout.unsubscribe = files.subscribe(() => {
368
+ }
369
+ private attachRoot(id: string, layout: SessionLayout, files: L3ProjectFileRuntime): void {
370
+ const unsubscribe = files.subscribe(() => {
371
+ if (layout.files !== files) return
277
372
  if (!files.state.activePath && !files.state.activeDiff && this.expandedWorkId !== id) {
278
373
  layout.windowOpen = false
279
374
  layout.activePane = 'chat'
280
375
  }
281
376
  this.publish()
282
377
  })
283
- this.workspaces.set(id, layout)
284
- return layout
378
+ layout.roots.set(projectPreviewDirectoryKey(files.cwd), { files, unsubscribe })
379
+ }
380
+ private switchFiles(id: string, layout: SessionLayout): void {
381
+ const directory = this.getDirectory(id)
382
+ const key = projectPreviewDirectoryKey(directory)
383
+ if (projectPreviewDirectoryKey(layout.files.cwd) === key) return
384
+ let root = layout.roots.get(key)
385
+ if (!root) {
386
+ const files = this.createFiles(id, directory)
387
+ this.attachRoot(id, layout, files)
388
+ root = layout.roots.get(key)!
389
+ }
390
+ layout.files = root.files
391
+ if (
392
+ !root.files.state.activePath &&
393
+ !root.files.state.activeDiff &&
394
+ this.expandedWorkId !== id
395
+ ) {
396
+ layout.windowOpen = false
397
+ layout.activePane = 'chat'
398
+ }
399
+ }
400
+ private disposeLayout(layout: SessionLayout): void {
401
+ for (const root of layout.roots.values()) {
402
+ root.unsubscribe()
403
+ root.files.dispose()
404
+ }
405
+ layout.roots.clear()
285
406
  }
286
407
  private publish(): void {
287
408
  this.revision += 1
@@ -53,8 +53,11 @@ export function L2FileWorkspaceView({
53
53
  }: L2FileWorkspaceViewProps): React.JSX.Element | null {
54
54
  const { t } = useTranslation('workbench')
55
55
  useSyncExternalStore(runtime.subscribe, runtime.getRevision, runtime.getRevision)
56
- const { workId, cwd, projectName } = workSession
56
+ const { workId, projectName } = workSession
57
+ const cwd = runtime.getDirectory(workId)
57
58
  const workspace = runtime.getWorkspace(workId)
59
+ const directoryName = cwd.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) ?? cwd
60
+ const previewProjectName = cwd === workSession.cwd ? projectName : directoryName
58
61
  const [directoryOpen, setDirectoryOpen] = useState(true)
59
62
  const documentRef = useRef<L3ProjectDocumentHandle>(null)
60
63
  const expanded = presentation === 'expanded'
@@ -137,9 +140,9 @@ export function L2FileWorkspaceView({
137
140
  </p>
138
141
  <p
139
142
  className="truncate text-xs text-muted-foreground"
140
- title={`${projectName} · ${diffPath ?? activePath ?? cwd}`}
143
+ title={`${cwd} · ${diffPath ?? activePath ?? ''}`}
141
144
  >
142
- {projectName}
145
+ {previewProjectName}
143
146
  {(diffPath ?? activePath) ? ` · ${diffPath ?? activePath}` : ''}
144
147
  </p>
145
148
  </div>
@@ -178,7 +181,7 @@ export function L2FileWorkspaceView({
178
181
  size="icon-sm"
179
182
  aria-label={t('newTerminal')}
180
183
  title={t('newTerminal')}
181
- onClick={() => onOpenTerminal(cwd)}
184
+ onClick={() => onOpenTerminal(workSession.cwd)}
182
185
  >
183
186
  <TerminalIcon className="size-4" />
184
187
  </Button>
@@ -197,7 +200,7 @@ export function L2FileWorkspaceView({
197
200
  </div>
198
201
  <L2ProjectFileBrowser
199
202
  workId={workId}
200
- cwd={cwd}
203
+ cwd={workSession.cwd}
201
204
  projectName={projectName}
202
205
  runtime={runtime}
203
206
  gitRuntime={gitRuntime}
@@ -217,6 +220,7 @@ export function L2FileWorkspaceView({
217
220
  onClose={(path, scope) => runtime.closeTabs(workId, path, scope)}
218
221
  />
219
222
  <L3ProjectDocumentView
223
+ key={cwd}
220
224
  ref={documentRef}
221
225
  runtime={runtime.getFileRuntime(workId)}
222
226
  gitRuntime={gitRuntime}
@@ -1,10 +1,31 @@
1
1
  'use client'
2
2
 
3
+ import { ChevronDownIcon, FolderIcon } from 'lucide-react'
4
+ import { useSyncExternalStore } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { projectPreviewDirectoryKey } from '@common/l4_foundation/file/l4-project-preview-contract'
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuTrigger,
10
+ DropdownMenuContent,
11
+ DropdownMenuGroup,
12
+ DropdownMenuLabel,
13
+ DropdownMenuRadioGroup,
14
+ DropdownMenuRadioItem
15
+ } from '@client/l4_foundation/ui/shadcn/dropdown-menu'
3
16
  import { L3ProjectFileBrowser } from '@client/l3_modules/project-files/l3-project-file-browser'
4
17
  import type { L2FileWorkspaceRuntime } from './l2-file-workspace-runtime'
5
18
  import type { L2GitWorkspaceRuntime } from '../git-workspace/l2-git-workspace-runtime'
6
19
  import type { L2GitBranchPickerTarget } from '../git-workspace/l2-git-workspace-model'
7
20
 
21
+ function directoryName(directory: string): string {
22
+ return directory.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) ?? directory
23
+ }
24
+ function compactDirectory(directory: string): string {
25
+ const parts = directory.replaceAll('\\', '/').split('/').filter(Boolean)
26
+ return parts.length <= 2 ? directory : `…/${parts.slice(-2).join('/')}`
27
+ }
28
+
8
29
  interface Props {
9
30
  workId: string
10
31
  cwd: string
@@ -23,15 +44,83 @@ export function L2ProjectFileBrowser({
23
44
  onOpenGitBranches,
24
45
  onFileOpened
25
46
  }: Props): React.JSX.Element {
47
+ const { t } = useTranslation('projectFiles')
48
+ useSyncExternalStore(runtime.subscribe, runtime.getRevision, runtime.getRevision)
49
+ const directory = runtime.getDirectory(workId)
50
+ const directories = runtime.getDirectories(workId)
51
+ const original = projectPreviewDirectoryKey(cwd) === projectPreviewDirectoryKey(directory)
52
+ const name = original ? projectName : directoryName(directory)
26
53
  return (
27
54
  <L3ProjectFileBrowser
28
- cwd={cwd}
29
- projectName={projectName}
55
+ cwd={directory}
56
+ projectName={name}
57
+ gitReadOnly={!original}
58
+ rootTitle={
59
+ directories.length > 1 ? (
60
+ <DropdownMenu>
61
+ <DropdownMenuTrigger
62
+ data-testid="project-preview-directory-trigger"
63
+ aria-label={t('selectPreviewDirectory')}
64
+ title={directory}
65
+ className="flex min-h-7 min-w-0 flex-1 items-center gap-1 rounded-md px-1 text-left hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
66
+ >
67
+ <span className="max-w-[45%] truncate text-xs font-semibold">{name}</span>
68
+ <span className="text-xs text-muted-foreground">·</span>
69
+ <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">
70
+ {compactDirectory(directory)}
71
+ </span>
72
+ <ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
73
+ </DropdownMenuTrigger>
74
+ <DropdownMenuContent
75
+ finalFocus
76
+ className="w-[23rem] min-w-0 max-w-[calc(100vw-1.5rem)]"
77
+ aria-label={t('previewDirectories')}
78
+ >
79
+ <DropdownMenuGroup>
80
+ <DropdownMenuLabel>{t('previewDirectories')}</DropdownMenuLabel>
81
+ <DropdownMenuRadioGroup
82
+ value={directory}
83
+ onValueChange={(value) => runtime.selectDirectory(workId, value)}
84
+ >
85
+ {directories.map((root) => (
86
+ <DropdownMenuRadioItem
87
+ key={projectPreviewDirectoryKey(root)}
88
+ value={root}
89
+ data-testid="project-preview-directory-option"
90
+ data-directory={root}
91
+ className="items-start py-2 data-checked:bg-muted"
92
+ >
93
+ <FolderIcon className="mt-0.5 size-4 text-muted-foreground" />
94
+ <span className="min-w-0 flex-1">
95
+ <span className="flex items-start gap-2">
96
+ <span className="min-w-0 flex-1 break-all">
97
+ {projectPreviewDirectoryKey(root) === projectPreviewDirectoryKey(cwd)
98
+ ? projectName
99
+ : directoryName(root)}
100
+ </span>
101
+ {projectPreviewDirectoryKey(root) === projectPreviewDirectoryKey(cwd) ? (
102
+ <span className="shrink-0 text-xs text-muted-foreground">
103
+ {t('sessionDirectory')}
104
+ </span>
105
+ ) : null}
106
+ </span>
107
+ <span className="mt-0.5 block break-all font-mono text-xs leading-5 text-muted-foreground">
108
+ {root}
109
+ </span>
110
+ </span>
111
+ </DropdownMenuRadioItem>
112
+ ))}
113
+ </DropdownMenuRadioGroup>
114
+ </DropdownMenuGroup>
115
+ </DropdownMenuContent>
116
+ </DropdownMenu>
117
+ ) : undefined
118
+ }
30
119
  runtime={runtime.getFileRuntime(workId)}
31
120
  gitRuntime={gitRuntime}
32
- onOpenGitBranches={(repositoryRoot) =>
33
- onOpenGitBranches({ workId, cwd, projectName, repositoryRoot })
34
- }
121
+ onOpenGitBranches={(repositoryRoot) => {
122
+ if (original) onOpenGitBranches({ workId, cwd, projectName, repositoryRoot })
123
+ }}
35
124
  onFileOpened={() => {
36
125
  runtime.showWindow(workId)
37
126
  onFileOpened?.()
@@ -0,0 +1,48 @@
1
+ import type { L2WorkSessionListItem } from '@common/l2_biz/work-session/l2-work-session-contract'
2
+ import {
3
+ L4_PROJECT_PREVIEW_PLUGIN_KEY,
4
+ readL4ProjectPreviewState
5
+ } from '@common/l4_foundation/file/l4-project-preview-contract'
6
+ import type { L2WorkbenchChatRuntime } from '../l2-workbench-chat'
7
+ import type { L2GitWorkspaceRuntime } from '../git-workspace/l2-git-workspace-runtime'
8
+ import type { L2FileWorkspaceRuntime } from './l2-file-workspace-runtime'
9
+
10
+ export function syncL2ProjectPreviewGitReferences(
11
+ files: L2FileWorkspaceRuntime,
12
+ git: L2GitWorkspaceRuntime,
13
+ sessions: readonly L2WorkSessionListItem[],
14
+ focusedWorkId: string | null
15
+ ): void {
16
+ git.reconcileWorkSessions(
17
+ sessions,
18
+ focusedWorkId,
19
+ sessions.flatMap(({ workId }) => files.getDirectories(workId).map((cwd) => ({ workId, cwd })))
20
+ )
21
+ }
22
+
23
+ export function subscribeL2ProjectPreviewDirectories(
24
+ chat: L2WorkbenchChatRuntime,
25
+ files: L2FileWorkspaceRuntime,
26
+ git: L2GitWorkspaceRuntime,
27
+ getSessions: () => readonly L2WorkSessionListItem[],
28
+ getFocusedWorkId: () => string | null
29
+ ): () => void {
30
+ const unsubscribeChat = chat.subscribeSourceEvents(({ source, event }, state) => {
31
+ if (event.type !== 'session_sync' && event.type !== 'runtime_update') return
32
+ files.applyPreviewDirectories(
33
+ source,
34
+ readL4ProjectPreviewState(state.runtime.plugins[L4_PROJECT_PREVIEW_PLUGIN_KEY])
35
+ )
36
+ syncL2ProjectPreviewGitReferences(files, git, getSessions(), getFocusedWorkId())
37
+ })
38
+ for (const session of getSessions()) {
39
+ const state = chat.getSourceState(session)
40
+ if (state.syncStatus === 'loading') continue
41
+ files.applyPreviewDirectories(
42
+ session,
43
+ readL4ProjectPreviewState(state.runtime.plugins[L4_PROJECT_PREVIEW_PLUGIN_KEY])
44
+ )
45
+ }
46
+ syncL2ProjectPreviewGitReferences(files, git, getSessions(), getFocusedWorkId())
47
+ return unsubscribeChat
48
+ }