@jetcrab/pi-desk 1.1.29-dev → 1.1.30-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 (62) hide show
  1. package/docs/plugins.md +2 -2
  2. package/package.json +1 -1
  3. package/src/client/l1_entry/pages/l1-error-page.tsx +1 -1
  4. package/src/client/l1_entry/providers/l1-app-providers.tsx +2 -0
  5. package/src/client/l2_biz/auth/l2-login.module.css +1 -1
  6. package/src/client/l2_biz/plugin-host/l2-plugin-composer-panel.tsx +1 -1
  7. package/src/client/l2_biz/plugin-host/l2-plugin-contribution-host.tsx +1 -1
  8. package/src/client/l2_biz/plugin-host/l2-plugin-session-header-layout.ts +6 -3
  9. package/src/client/l2_biz/plugin-host/l2-plugin-session-header.tsx +75 -18
  10. package/src/client/l2_biz/project-preview/l2-project-preview-dialog.tsx +2 -2
  11. package/src/client/l2_biz/project-preview/l2-project-preview-page.tsx +1 -1
  12. package/src/client/l2_biz/settings/l2-settings.tsx +1 -1
  13. package/src/client/l2_biz/settings/l2-skills-settings.tsx +1 -1
  14. package/src/client/l2_biz/task-center/l2-task-center.tsx +1 -1
  15. package/src/client/l2_biz/workbench/file-workspace/l2-plugin-standalone-file-preview.tsx +1 -1
  16. package/src/client/l2_biz/workbench/file-workspace/l2-work-session-file-preview.tsx +1 -1
  17. package/src/client/l2_biz/workbench/l2-workbench.tsx +1 -1
  18. package/src/client/l4_foundation/desktop-window/l4-desktop-titlebar.tsx +188 -0
  19. package/src/client/l4_foundation/desktop-window/l4-desktop-window.css +107 -0
  20. package/src/client/l4_foundation/desktop-window/l4-desktop-window.ts +20 -0
  21. package/src/client/l4_foundation/ui/l4-app-dialog.tsx +2 -2
  22. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  23. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  24. package/temp/build/pi-desk/release/.next/required-server-files.js +2 -2
  25. package/temp/build/pi-desk/release/.next/required-server-files.json +2 -2
  26. package/temp/build/pi-desk/release/.next/server/app/_global-error/page.js +1 -1
  27. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  28. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  29. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +3 -3
  30. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +3 -3
  31. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +3 -3
  32. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  33. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  34. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  35. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  36. package/temp/build/pi-desk/release/.next/server/app/page.js +5 -5
  37. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  38. package/temp/build/pi-desk/release/.next/server/app/project-preview/page.js +1 -1
  39. package/temp/build/pi-desk/release/.next/server/app/project-preview/page_client-reference-manifest.js +1 -1
  40. package/temp/build/pi-desk/release/.next/server/chunks/27344.js +1 -1
  41. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +2 -2
  42. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  43. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  44. package/temp/build/pi-desk/release/.next/static/chunks/{65232-a8571f78020eede2.js → 65232-f5b71cec787a30ec.js} +1 -1
  45. package/temp/build/pi-desk/release/.next/static/chunks/app/error-1ab62762c5f4144f.js +1 -0
  46. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-ca5ef5994165eb2e.js +1 -0
  47. package/temp/build/pi-desk/release/.next/static/chunks/app/not-found-b8b9b7ef5ccb7601.js +1 -0
  48. package/temp/build/pi-desk/release/.next/static/chunks/app/page-978906de76a8dcfe.js +158 -0
  49. package/temp/build/pi-desk/release/.next/static/chunks/app/project-preview/{page-e1dbfcfc0f333e01.js → page-aaa845016a934245.js} +1 -1
  50. package/temp/build/pi-desk/release/.next/static/chunks/{webpack-c0b805df46fcf8b3.js → webpack-a84310d111797bef.js} +1 -1
  51. package/temp/build/pi-desk/release/.next/static/css/053a8d82c09326cd.css +1 -0
  52. package/temp/build/pi-desk/release/.next/static/css/1cc92eec583aa639.css +1 -0
  53. package/temp/build/pi-desk/release/.next/static/css/3c59ac068ae404da.css +1 -0
  54. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  55. package/temp/build/pi-desk/release/.next/static/chunks/app/error-216b14b5da089ed3.js +0 -1
  56. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-2c6a573d3004516e.js +0 -1
  57. package/temp/build/pi-desk/release/.next/static/chunks/app/not-found-720cdf6a606b821a.js +0 -1
  58. package/temp/build/pi-desk/release/.next/static/chunks/app/page-f78be007d97f0d95.js +0 -158
  59. package/temp/build/pi-desk/release/.next/static/css/2bdcf40eca12b794.css +0 -1
  60. package/temp/build/pi-desk/release/.next/static/css/a677e7fbf6e2173e.css +0 -1
  61. /package/temp/build/pi-desk/release/.next/static/{Yyfg-r17aYmEzlYYlIwDo → ErYBfYXAir5b-6rlacGvL}/_buildManifest.js +0 -0
  62. /package/temp/build/pi-desk/release/.next/static/{Yyfg-r17aYmEzlYYlIwDo → ErYBfYXAir5b-6rlacGvL}/_ssgManifest.js +0 -0
package/docs/plugins.md CHANGED
@@ -180,10 +180,10 @@ plugin.registerSessionHeaderItem('session-info', {
180
180
  ```
181
181
 
182
182
  - `action` 只接受固定 `icon`、`label`、`onSelect({ source, host, signal })`,不提供动态图标、文字或状态订阅。宿主显示图标加文字,异步操作期间防止重复点击;失败记录局部错误,失效后的回调不得继续使用旧 Host。信号属于该展示生命周期,不会回滚服务端已经产生的副作用。
183
- - `custom` 只提供 `load()`,宿主不包装点击回调。inline custom 必须额外声明正数 `minWidthRem`,单位为 `rem`;宿主按此预留可用宽度,不通过重复挂载业务视图测量。自定义内容应适应获得的容器,不能靠超宽 DOM 挤占标题。overflow custom 不声明宽度。
183
+ - `custom` 只提供 `load()`,宿主不包装点击回调。inline custom 必须额外声明正数 `minWidthRem`,单位为 `rem`;有内容时才按此分配宽度。加载中和空内容不占宽度或间距,宿主只观察当前真实挂载,不复制业务视图测量。自定义内容应适应获得的容器,不能靠超宽 DOM 挤占标题。overflow custom 不声明宽度,加载中和空内容也不预留空行。
184
184
  - 自定义 Target 为 `{ kind: 'session-header-item', source, presentation: 'inline' | 'overflow' }`。两种表现可以使用不同模块,也可以复用同一个 `{ mount }`;loader 分别按表现缓存和重试。可复用上面的 `session-info.js`,或使用公共 React/UI 构建交互视图。
185
185
  - 标题项按插件名和注册名稳定排列;宽度不足时从末尾收进更多,宿主保留标题及必要操作。普通按钮选择后关闭更多;自定义区域内点击不会被转发,也不会自动关闭更多。Escape 或外部点击关闭浮层,键盘与触控均可操作。
186
- - 收纳会卸载 inline 自定义视图;只有更多打开时才挂载 overflow 自定义视图,关闭后释放。动态状态与运行/停止等复杂交互由自定义视图自行实现;业务状态归插件运行态,不归 mount,也不能只存于会被卸载的组件局部状态。卸载只清理视图资源,不停止项目进程。
186
+ - inline 自定义视图先静默挂载,内容出现后参与宽度计算;收纳会卸载该视图。只有更多打开时才挂载 overflow 自定义视图,关闭后释放。动态状态与运行/停止等复杂交互由自定义视图自行实现;业务状态归插件运行态,不归 mount,也不能只存于会被卸载的组件局部状态。卸载只清理视图资源,不停止项目进程。
187
187
 
188
188
  两种注册均限定 Browser Entry 同步阶段,名称规则与其他贡献相同。它们需要支持对应方法的宿主和 SDK;旧宿主缺少能力时禁用这项入口,不使用 `registerContribution` 别名或访问宿主 DOM 代替。现有 `composer-panel` 仍是人工打开的会话弹窗,不是上下方挂件。
189
189
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jetcrab/pi-desk",
3
- "version": "1.1.29-dev",
3
+ "version": "1.1.30-dev",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,7 +13,7 @@ export function L1ErrorPage({
13
13
  }): React.JSX.Element {
14
14
  const { t } = useTranslation('common')
15
15
  return (
16
- <main className="flex min-h-dvh items-center justify-center bg-background p-4 text-foreground">
16
+ <main className="flex min-h-[var(--pi-desk-content-height)] items-center justify-center bg-background p-4 text-foreground">
17
17
  <div className="w-full max-w-md space-y-4 text-center">
18
18
  <h1 className="text-xl font-semibold">{t(notFound ? 'notFound' : 'pageError')}</h1>
19
19
  <div className="flex flex-wrap items-center justify-center gap-2">
@@ -24,6 +24,7 @@ import { L4AppToastProvider } from '@client/l4_foundation/ui/l4-app-toast'
24
24
  import { TooltipProvider } from '@client/l4_foundation/ui/shadcn/tooltip'
25
25
  import { createL4BrowserUuid } from '@client/l4_foundation/lib/l4-browser-uuid'
26
26
  import { L4ClientIdContext } from '@client/l4_foundation/lib/l4-client-id'
27
+ import { L4DesktopTitlebar } from '@client/l4_foundation/desktop-window/l4-desktop-titlebar'
27
28
 
28
29
  const resources = {
29
30
  en: {
@@ -76,6 +77,7 @@ export function L1AppProviders({
76
77
  <L4CodePreferencesProvider>
77
78
  <L4RegionProvider resources={resources} initialSettings={initialSettings}>
78
79
  <L4AppToastProvider>
80
+ <L4DesktopTitlebar />
79
81
  <L4RegionNotice />
80
82
  <TooltipProvider>{children}</TooltipProvider>
81
83
  </L4AppToastProvider>
@@ -1,7 +1,7 @@
1
1
  .page {
2
2
  display: grid;
3
3
  grid-template-rows: auto 1fr;
4
- min-height: 100dvh;
4
+ min-height: var(--pi-desk-content-height, 100dvh);
5
5
  padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
6
6
  max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
7
7
  background: var(--background);
@@ -35,7 +35,7 @@ export function L2PluginComposerPanel({
35
35
  <L4AppDialogRoot open onOpenChange={(open) => !open && onClose()}>
36
36
  <L4AppDialogContent
37
37
  finalFocus={false}
38
- className="h-[min(720px,calc(100dvh-2rem))] max-w-4xl max-sm:h-dvh max-sm:max-h-dvh max-sm:w-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0"
38
+ className="h-[min(720px,calc(100dvh-2rem))] max-w-4xl max-sm:h-[var(--pi-desk-content-height)] max-sm:max-h-[var(--pi-desk-content-height)] max-sm:w-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0"
39
39
  >
40
40
  <L4AppDialogHeader className="border-b px-4 py-3 pr-12">
41
41
  <div className="flex min-w-0 items-center gap-3">
@@ -237,7 +237,7 @@ export function L2PluginContributionHost({
237
237
  state.status === 'error' && 'hidden'
238
238
  )}
239
239
  />
240
- {state.status === 'loading' ? (
240
+ {state.status === 'loading' && kind !== 'widget' && kind !== 'session-header-item' ? (
241
241
  <div
242
242
  role="status"
243
243
  className={cn(
@@ -4,14 +4,17 @@ export function calculateL2PluginSessionHeaderInlineCount(
4
4
  moreWidth: number,
5
5
  gap: number
6
6
  ): number {
7
- const total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, widths.length - 1) * gap
7
+ const occupiedCount = widths.filter((width) => width > 0).length
8
+ const total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, occupiedCount - 1) * gap
8
9
  if (total <= availableWidth) return widths.length
9
10
 
10
11
  let used = moreWidth
11
12
  let count = 0
12
13
  for (const width of widths) {
13
- if (used + gap + width > availableWidth) break
14
- used += gap + width
14
+ if (width > 0) {
15
+ if (used + gap + width > availableWidth) break
16
+ used += gap + width
17
+ }
15
18
  count += 1
16
19
  }
17
20
  return count
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { EllipsisIcon } from 'lucide-react'
4
- import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
4
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
5
5
  import { useTranslation } from 'react-i18next'
6
6
  import type { BrowserSessionHeaderItemTarget, PluginSource } from '@jetcrab/pi-desk-sdk/browser'
7
7
  import { useL4PluginRegistry } from '@client/l4_foundation/plugin-host/l4-plugin-host-context'
@@ -14,6 +14,7 @@ import {
14
14
  PopoverTitle,
15
15
  PopoverTrigger
16
16
  } from '@client/l4_foundation/ui/shadcn/popover'
17
+ import { cn } from '@client/l4_foundation/lib/l4-utils'
17
18
  import { L2PluginContributionHost } from './l2-plugin-contribution-host'
18
19
  import { calculateL2PluginSessionHeaderInlineCount } from './l2-plugin-session-header-layout'
19
20
  import { L2PluginSessionHeaderAction } from './views/l2-plugin-session-header-action'
@@ -36,12 +37,14 @@ function HeaderItemView({
36
37
  item,
37
38
  source,
38
39
  presentation,
39
- onSelected
40
+ onSelected,
41
+ onContentChange
40
42
  }: {
41
43
  item: HeaderItem
42
44
  source: PluginSource
43
45
  presentation: 'inline' | 'overflow'
44
46
  onSelected?: () => void
47
+ onContentChange?: (item: HeaderItem, hasContent: boolean) => void
45
48
  }): React.JSX.Element {
46
49
  const definition = item.descriptor[presentation]
47
50
  const target = useMemo<BrowserSessionHeaderItemTarget>(
@@ -49,6 +52,22 @@ function HeaderItemView({
49
52
  [presentation, source]
50
53
  )
51
54
  const sourceKey = JSON.stringify([source.workId, source.sessionId, source.branchId, presentation])
55
+ const contentRef = useRef<HTMLDivElement>(null)
56
+ const [hasContent, setHasContent] = useState(false)
57
+
58
+ useLayoutEffect(() => {
59
+ const content = contentRef.current
60
+ if (!content) return
61
+ const measure = (): void => {
62
+ const visible = content.getBoundingClientRect().height > 0
63
+ setHasContent(visible)
64
+ onContentChange?.(item, visible)
65
+ }
66
+ const observer = new ResizeObserver(measure)
67
+ observer.observe(content)
68
+ return () => observer.disconnect()
69
+ }, [item, onContentChange, sourceKey])
70
+
52
71
  if (definition.type === 'action') {
53
72
  return (
54
73
  <L2PluginSessionHeaderAction
@@ -68,21 +87,29 @@ function HeaderItemView({
68
87
  data-plugin-name={item.pluginName}
69
88
  data-contribution-name={item.descriptor.contributionName}
70
89
  data-presentation={presentation}
71
- className="min-w-0 shrink-0"
90
+ className={cn(
91
+ 'min-w-0 shrink-0',
92
+ presentation === 'overflow' && 'w-full',
93
+ !hasContent && 'pointer-events-none invisible absolute'
94
+ )}
95
+ aria-hidden={!hasContent}
96
+ inert={!hasContent}
72
97
  style={
73
98
  presentation === 'inline' && item.descriptor.inline.type === 'custom'
74
99
  ? { width: `${item.descriptor.inline.minWidthRem}rem` }
75
100
  : undefined
76
101
  }
77
102
  >
78
- <L2PluginContributionHost
79
- pluginName={item.pluginName}
80
- kind="session-header-item"
81
- contributionName={item.descriptor.contributionName}
82
- target={target}
83
- targetKey={sourceKey}
84
- layout="inline"
85
- />
103
+ <div ref={contentRef}>
104
+ <L2PluginContributionHost
105
+ pluginName={item.pluginName}
106
+ kind="session-header-item"
107
+ contributionName={item.descriptor.contributionName}
108
+ target={target}
109
+ targetKey={sourceKey}
110
+ layout="inline"
111
+ />
112
+ </div>
86
113
  </div>
87
114
  )
88
115
  }
@@ -106,16 +133,31 @@ export function L2PluginSessionHeader({
106
133
  )
107
134
  const rootRef = useRef<HTMLDivElement>(null)
108
135
  const measurementsRef = useRef<HTMLDivElement>(null)
136
+ const customContentRef = useRef(new Map<HeaderItem, boolean>())
137
+ const measureRef = useRef<(() => void) | null>(null)
138
+ const onContentChange = useCallback((item: HeaderItem, hasContent: boolean): void => {
139
+ if (customContentRef.current.get(item) === hasContent) return
140
+ customContentRef.current.set(item, hasContent)
141
+ measureRef.current?.()
142
+ }, [])
109
143
  const [measurement, setMeasurement] = useState<{
110
144
  items: readonly HeaderItem[]
111
145
  count: number
146
+ occupied: boolean
112
147
  } | null>(null)
113
148
  const [open, setOpen] = useState(false)
114
- const count = measurement?.items === items ? measurement.count : 0
149
+ const count = measurement?.items === items ? measurement.count : items.length
150
+ const occupied =
151
+ measurement?.items === items
152
+ ? measurement.occupied
153
+ : items.some((item) => item.descriptor.inline.type === 'action')
115
154
  const inlineItems = items.slice(0, count)
116
155
  const overflowItems = items.slice(count)
117
156
 
118
157
  useLayoutEffect(() => {
158
+ for (const item of customContentRef.current.keys()) {
159
+ if (!items.includes(item)) customContentRef.current.delete(item)
160
+ }
119
161
  const root = rootRef.current
120
162
  const measurements = measurementsRef.current
121
163
  if (!root || !measurements || items.length === 0) return
@@ -127,42 +169,57 @@ export function L2PluginSessionHeader({
127
169
  const gap = gapProbe.getBoundingClientRect().width
128
170
  const available =
129
171
  root.getBoundingClientRect().width - titleProbe.getBoundingClientRect().width - gap
172
+ const widths = probes.map((probe, index) =>
173
+ items[index].descriptor.inline.type === 'custom' &&
174
+ !customContentRef.current.get(items[index])
175
+ ? 0
176
+ : probe.getBoundingClientRect().width
177
+ )
130
178
  const nextCount = calculateL2PluginSessionHeaderInlineCount(
131
- probes.map((probe) => probe.getBoundingClientRect().width),
179
+ widths,
132
180
  available,
133
181
  moreProbe.getBoundingClientRect().width,
134
182
  gap
135
183
  )
184
+ const nextOccupied = widths.some((width) => width > 0)
136
185
  if (nextCount === items.length) setOpen(false)
137
186
  setMeasurement((current) =>
138
- current?.items === items && current.count === nextCount
187
+ current?.items === items && current.count === nextCount && current.occupied === nextOccupied
139
188
  ? current
140
- : { items, count: nextCount }
189
+ : { items, count: nextCount, occupied: nextOccupied }
141
190
  )
142
191
  }
192
+ measureRef.current = measure
143
193
  const observer = new ResizeObserver(measure)
144
194
  observer.observe(root)
145
195
  for (const probe of [...probes, titleProbe, moreProbe, gapProbe]) observer.observe(probe)
146
196
  measure()
147
- return () => observer.disconnect()
197
+ return () => {
198
+ measureRef.current = null
199
+ observer.disconnect()
200
+ }
148
201
  }, [items])
149
202
 
150
203
  return (
151
204
  <div
152
205
  ref={rootRef}
153
- className="relative flex min-w-0 flex-1 items-center gap-1"
206
+ className="relative flex min-w-0 flex-1 items-center"
154
207
  data-testid="plugin-session-header"
155
208
  >
156
209
  <div className="min-w-[3rem] flex-1">{title}</div>
157
210
  {items.length > 0 ? (
158
211
  <>
159
- <div className="flex shrink-0 items-center gap-1" data-testid="session-header-items">
212
+ <div
213
+ className={cn('flex shrink-0 items-center gap-1', occupied && 'ml-1')}
214
+ data-testid="session-header-items"
215
+ >
160
216
  {inlineItems.map((item) => (
161
217
  <HeaderItemView
162
218
  key={itemKey(item)}
163
219
  item={item}
164
220
  source={source}
165
221
  presentation="inline"
222
+ onContentChange={onContentChange}
166
223
  />
167
224
  ))}
168
225
  {overflowItems.length > 0 ? (
@@ -41,8 +41,8 @@ export function L2ProjectPreviewDialog({
41
41
  data-testid="project-preview-dialog"
42
42
  className={cn(
43
43
  'h-[min(94dvh,1080px)] w-[min(96dvw,1600px)] max-w-none gap-0 p-0',
44
- maximized && 'h-dvh max-h-none w-dvw rounded-none',
45
- 'max-sm:h-dvh max-sm:max-h-none max-sm:w-dvw max-sm:rounded-none'
44
+ maximized && 'h-[var(--pi-desk-content-height)] max-h-none w-dvw rounded-none',
45
+ 'max-sm:h-[var(--pi-desk-content-height)] max-sm:max-h-none max-sm:w-dvw max-sm:rounded-none'
46
46
  )}
47
47
  >
48
48
  <L4AppDialogDescription className="sr-only">
@@ -34,7 +34,7 @@ export function L2ProjectPreviewPage({
34
34
  const { t } = useTranslation('projectHistory')
35
35
  return (
36
36
  <main
37
- className="flex h-dvh min-w-0 flex-col overflow-hidden bg-background text-foreground"
37
+ className="flex h-[var(--pi-desk-content-height)] min-w-0 flex-col overflow-hidden bg-background text-foreground"
38
38
  data-testid="project-preview-page"
39
39
  >
40
40
  {cwd ? (
@@ -281,7 +281,7 @@ export function L2Settings({
281
281
  }}
282
282
  >
283
283
  <L4AppDialogContent
284
- className="h-[min(48rem,calc(100dvh-2rem))] max-w-6xl max-sm:h-dvh max-sm:max-h-dvh max-sm:w-dvw max-sm:max-w-none max-sm:rounded-none max-sm:border-0"
284
+ className="h-[min(48rem,calc(100dvh-2rem))] max-w-6xl max-sm:h-[var(--pi-desk-content-height)] max-sm:max-h-[var(--pi-desk-content-height)] max-sm:w-dvw max-sm:max-w-none max-sm:rounded-none max-sm:border-0"
285
285
  finalFocus={false}
286
286
  showCloseButton={!guardPending}
287
287
  >
@@ -209,7 +209,7 @@ export function L2SkillsSettings({
209
209
  </div>
210
210
  <L4AppDialogRoot open={drawerOpen} onOpenChange={setDrawerOpen}>
211
211
  <L4AppDialogContent
212
- className="left-0 top-0 z-[130] h-dvh max-h-dvh w-[min(360px,calc(100dvw-2rem))] max-w-none translate-x-0 translate-y-0 rounded-l-none"
212
+ className="left-0 top-[var(--pi-desk-window-inset)] z-[130] h-[var(--pi-desk-content-height)] max-h-[var(--pi-desk-content-height)] w-[min(360px,calc(100dvw-2rem))] max-w-none translate-x-0 translate-y-0 rounded-l-none"
213
213
  finalFocus={false}
214
214
  >
215
215
  <L4AppDialogHeader className="shrink-0 border-b px-4 py-3 pr-12">
@@ -468,7 +468,7 @@ function TaskCenterPanel({
468
468
  <L4AppDialogContent
469
469
  aria-label={t('title')}
470
470
  showCloseButton={false}
471
- className="h-[calc(100dvh-2rem)] max-w-[70rem] flex-row bg-background max-lg:h-dvh max-lg:max-h-dvh max-lg:w-dvw max-lg:max-w-none max-lg:rounded-none max-lg:border-0"
471
+ className="h-[calc(100dvh-2rem)] max-w-[70rem] flex-row bg-background max-lg:h-[var(--pi-desk-content-height)] max-lg:max-h-[var(--pi-desk-content-height)] max-lg:w-dvw max-lg:max-w-none max-lg:rounded-none max-lg:border-0"
472
472
  >
473
473
  <div
474
474
  className={cn(
@@ -152,7 +152,7 @@ export function L2PluginStandaloneFilePreview({
152
152
  <L4AppDialogContent
153
153
  className={
154
154
  mobile
155
- ? 'inset-0 h-dvh max-h-none w-dvw max-w-none translate-x-0 translate-y-0 rounded-none border-0 ring-0'
155
+ ? 'inset-x-0 top-[var(--pi-desk-window-inset)] bottom-0 h-[var(--pi-desk-content-height)] max-h-none w-dvw max-w-none translate-x-0 translate-y-0 rounded-none border-0 ring-0'
156
156
  : 'h-[min(94dvh,1080px)] w-[min(96dvw,1600px)] max-w-none'
157
157
  }
158
158
  >
@@ -75,7 +75,7 @@ export function L2WorkSessionFilePreview({
75
75
  }}
76
76
  className={
77
77
  mobile
78
- ? 'inset-0 h-dvh max-h-none w-dvw max-w-none translate-x-0 translate-y-0 rounded-none border-0 ring-0'
78
+ ? 'inset-x-0 top-[var(--pi-desk-window-inset)] bottom-0 h-[var(--pi-desk-content-height)] max-h-none w-dvw max-w-none translate-x-0 translate-y-0 rounded-none border-0 ring-0'
79
79
  : 'h-[min(94dvh,1080px)] w-[min(96dvw,1600px)] max-w-none'
80
80
  }
81
81
  finalFocus={() => {
@@ -2040,7 +2040,7 @@ export function L2Workbench({
2040
2040
  )
2041
2041
 
2042
2042
  return (
2043
- <main className="pi-desk-workbench flex h-dvh overflow-hidden bg-background">
2043
+ <main className="pi-desk-workbench flex h-[var(--pi-desk-content-height)] overflow-hidden bg-background">
2044
2044
  <L2WorkbenchSidebarLayout
2045
2045
  mobile={mobile}
2046
2046
  open={sidebarOpen}
@@ -0,0 +1,188 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState, type MouseEvent } from 'react'
4
+ import {
5
+ getL4DesktopWindow,
6
+ type L4DesktopWindow,
7
+ type L4DesktopWindowAction,
8
+ type L4DesktopWindowState
9
+ } from './l4-desktop-window'
10
+ import './l4-desktop-window.css'
11
+
12
+ const labels = {
13
+ 'zh-CN': {
14
+ window: '窗口',
15
+ minimize: '最小化',
16
+ maximize: '最大化',
17
+ restore: '还原',
18
+ close: '关闭'
19
+ },
20
+ en: {
21
+ window: 'Window',
22
+ minimize: 'Minimize',
23
+ maximize: 'Maximize',
24
+ restore: 'Restore',
25
+ close: 'Close'
26
+ }
27
+ }
28
+
29
+ export function L4DesktopTitlebar(): React.JSX.Element {
30
+ const adapter = useRef<L4DesktopWindow | null>(null)
31
+ const [state, setState] = useState<L4DesktopWindowState | null>(null)
32
+ const [title, setTitle] = useState('Pi Desk')
33
+ const [locale, setLocale] = useState<'zh-CN' | 'en'>('zh-CN')
34
+ const [focused, setFocused] = useState(true)
35
+
36
+ useEffect(() => {
37
+ const desktop = getL4DesktopWindow()
38
+ if (!desktop) return
39
+ adapter.current = desktop
40
+ let disposed = false
41
+ let frame = 0
42
+ const root = document.documentElement
43
+ const readTitle = (): void => {
44
+ setTitle(document.title || 'Pi Desk')
45
+ setLocale(root.lang.startsWith('zh') ? 'zh-CN' : 'en')
46
+ }
47
+ const refreshState = (): void => {
48
+ cancelAnimationFrame(frame)
49
+ frame = requestAnimationFrame(() => {
50
+ void desktop
51
+ .invoke('state')
52
+ .then((next) => {
53
+ if (!disposed) setState((current) => (current ? next : null))
54
+ })
55
+ .catch((error: unknown) => {
56
+ console.error('[desktop-window] 读取窗口状态失败', error)
57
+ })
58
+ })
59
+ }
60
+ const onFocus = (): void => {
61
+ setFocused(true)
62
+ }
63
+ const onBlur = (): void => {
64
+ setFocused(false)
65
+ }
66
+ const observer = new MutationObserver(readTitle)
67
+ observer.observe(document.head, { childList: true, subtree: true, characterData: true })
68
+ observer.observe(root, { attributes: true, attributeFilter: ['lang'] })
69
+ window.addEventListener('resize', refreshState)
70
+ window.addEventListener('focus', onFocus)
71
+ window.addEventListener('blur', onBlur)
72
+
73
+ // 按钮已挂载并绑定事件后才关闭系统装饰;旧页面不会调用此能力。
74
+ void desktop
75
+ .invoke('attach')
76
+ .then((next) => {
77
+ if (disposed) return
78
+ readTitle()
79
+ setFocused(document.hasFocus())
80
+ root.dataset.piDeskWindow = ''
81
+ setState(next)
82
+ })
83
+ .catch((error: unknown) => {
84
+ console.error('[desktop-window] 启用自定义标题栏失败,保留系统标题栏', error)
85
+ })
86
+
87
+ return () => {
88
+ disposed = true
89
+ adapter.current = null
90
+ observer.disconnect()
91
+ cancelAnimationFrame(frame)
92
+ window.removeEventListener('resize', refreshState)
93
+ window.removeEventListener('focus', onFocus)
94
+ window.removeEventListener('blur', onBlur)
95
+ delete root.dataset.piDeskWindow
96
+ void desktop.invoke('detach').catch((error: unknown) => {
97
+ console.error('[desktop-window] 恢复系统标题栏失败', error)
98
+ })
99
+ }
100
+ }, [])
101
+
102
+ const run = async (action: L4DesktopWindowAction): Promise<void> => {
103
+ const desktop = adapter.current
104
+ if (!desktop) return
105
+ try {
106
+ const next = await desktop.invoke(action)
107
+ if (adapter.current === desktop) setState(next)
108
+ } catch (error) {
109
+ console.error(`[desktop-window] 窗口操作失败 action=${action}`, error)
110
+ try {
111
+ await desktop.invoke('detach')
112
+ if (adapter.current !== desktop) return
113
+ delete document.documentElement.dataset.piDeskWindow
114
+ setState(null)
115
+ } catch (restoreError) {
116
+ console.error('[desktop-window] 恢复系统标题栏失败', restoreError)
117
+ }
118
+ }
119
+ }
120
+ const onDrag = (event: MouseEvent<HTMLElement>): void => {
121
+ if (event.button !== 0 || (event.target as Element).closest('button')) return
122
+ event.preventDefault()
123
+ void run(event.detail === 2 ? 'toggle-maximize' : 'drag')
124
+ }
125
+ const text = labels[locale]
126
+ const maximizeLabel = state?.maximized ? text.restore : text.maximize
127
+
128
+ return (
129
+ <header
130
+ className="pi-desk-titlebar"
131
+ data-testid="desktop-titlebar"
132
+ data-focused={focused}
133
+ hidden={!state}
134
+ aria-label={text.window}
135
+ onMouseDown={onDrag}
136
+ >
137
+ <span className="pi-desk-titlebar-title" title={title}>
138
+ {title}
139
+ </span>
140
+ <div className="pi-desk-titlebar-controls">
141
+ <button
142
+ type="button"
143
+ aria-label={text.minimize}
144
+ title={text.minimize}
145
+ onMouseDown={(event) => event.preventDefault()}
146
+ onClick={() => {
147
+ void run('minimize')
148
+ }}
149
+ >
150
+ <svg viewBox="0 0 16 16" aria-hidden="true">
151
+ <path d="M3 8.5h10" />
152
+ </svg>
153
+ </button>
154
+ <button
155
+ type="button"
156
+ aria-label={maximizeLabel}
157
+ title={maximizeLabel}
158
+ onMouseDown={(event) => event.preventDefault()}
159
+ onClick={() => {
160
+ void run('toggle-maximize')
161
+ }}
162
+ >
163
+ <svg viewBox="0 0 16 16" aria-hidden="true">
164
+ {state?.maximized ? (
165
+ <path d="M5.5 5.5v-3h8v8h-3m-8-5h8v8h-8z" />
166
+ ) : (
167
+ <rect x="3.5" y="3.5" width="9" height="9" />
168
+ )}
169
+ </svg>
170
+ </button>
171
+ <button
172
+ type="button"
173
+ className="pi-desk-titlebar-close"
174
+ aria-label={text.close}
175
+ title={text.close}
176
+ onMouseDown={(event) => event.preventDefault()}
177
+ onClick={() => {
178
+ void run('close')
179
+ }}
180
+ >
181
+ <svg viewBox="0 0 16 16" aria-hidden="true">
182
+ <path d="m3.5 3.5 9 9m0-9-9 9" />
183
+ </svg>
184
+ </button>
185
+ </div>
186
+ </header>
187
+ )
188
+ }