@jetcrab/pi-desk 1.1.1-dev → 1.1.5-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 (117) hide show
  1. package/docs/pi-desk/README.md +47 -0
  2. package/docs/pi-desk/commands.md +76 -0
  3. package/docs/pi-desk/examples/local-application/browser/application.js +106 -0
  4. package/docs/pi-desk/examples/local-application/browser/entry.js +9 -0
  5. package/docs/pi-desk/examples/local-application/package.json +9 -0
  6. package/docs/pi-desk/examples/local-application/pi-desk.ts +11 -0
  7. package/docs/pi-desk/plugins.md +119 -0
  8. package/package.json +3 -2
  9. package/src/client/l2_biz/pi-settings/hooks/l2-use-pi-settings.ts +37 -21
  10. package/src/client/l2_biz/pi-settings/l2-pi-settings.tsx +37 -43
  11. package/src/client/l2_biz/pi-settings/views/l2-mcp-settings-editor.tsx +3 -1
  12. package/src/client/l2_biz/pi-settings/views/l2-mcp-settings-list.tsx +1 -1
  13. package/src/client/l2_biz/plugin-host/hooks/l2-use-plugin-catalog.ts +3 -1
  14. package/src/client/l2_biz/plugin-host/hooks/l2-use-plugin-download-source.ts +5 -1
  15. package/src/client/l2_biz/plugin-host/l2-plugin-capability-details.tsx +57 -41
  16. package/src/client/l2_biz/plugin-host/l2-plugin-management-controls.tsx +89 -116
  17. package/src/client/l2_biz/plugin-host/l2-plugin-management.tsx +492 -338
  18. package/src/client/l2_biz/plugin-host/l2-use-plugin-management.ts +72 -22
  19. package/src/client/l2_biz/plugin-host/views/l2-plugin-catalog-detail.tsx +104 -76
  20. package/src/client/l2_biz/plugin-host/views/l2-plugin-catalog-search.tsx +101 -56
  21. package/src/client/l2_biz/plugin-host/views/l2-plugin-direct-install.tsx +91 -59
  22. package/src/client/l2_biz/plugin-host/views/l2-plugin-download-source.tsx +36 -27
  23. package/src/client/l2_biz/plugin-host/views/l2-plugin-installed-list.tsx +218 -208
  24. package/src/client/l2_biz/plugin-host/views/l2-plugin-management-channel.tsx +12 -5
  25. package/src/client/l2_biz/plugin-host/views/l2-plugin-management-dialog.tsx +84 -106
  26. package/src/client/l2_biz/plugin-host/views/l2-plugin-readme.tsx +29 -15
  27. package/src/client/l2_biz/settings/hooks/l2-use-project-model-default-settings.ts +22 -11
  28. package/src/client/l2_biz/settings/l2-settings.tsx +6 -11
  29. package/src/client/l2_biz/settings/views/l2-project-model-default-settings.tsx +20 -14
  30. package/src/client/l2_biz/terminal/l2-terminal-workspace.tsx +35 -1
  31. package/src/client/l2_biz/workbench/file-workspace/l2-work-session-file-preview.tsx +6 -2
  32. package/src/client/l2_biz/workbench/hooks/l2-workbench-chat-viewport.ts +14 -1
  33. package/src/client/l2_biz/workbench/l2-workbench-branch-picker.tsx +33 -5
  34. package/src/client/l2_biz/workbench/l2-workbench-sidebar-layout.tsx +3 -1
  35. package/src/client/l2_biz/workbench/l2-workbench.tsx +20 -3
  36. package/src/client/l3_modules/conversation/l3-conversation-call-summary.tsx +0 -3
  37. package/src/client/l3_modules/project-files/l3-file-context-menu-content.tsx +1 -0
  38. package/src/client/l4_foundation/ui/l4-app-dialog.tsx +3 -1
  39. package/src/client/l4_foundation/ui/l4-image-viewer.tsx +21 -0
  40. package/src/client/l4_foundation/ui/shadcn/checkbox.tsx +27 -0
  41. package/src/client/l4_foundation/ui/shadcn/context-menu.tsx +8 -1
  42. package/src/client/l4_foundation/ui/shadcn/dropdown-menu.tsx +5 -0
  43. package/src/client/l4_foundation/ui/shadcn/popover.tsx +2 -0
  44. package/src/client/l4_foundation/ui/shadcn/select.tsx +2 -0
  45. package/src/client/l4_foundation/ui/shadcn/switch.tsx +31 -0
  46. package/src/client/l4_foundation/ui/shadcn/tabs.tsx +74 -0
  47. package/src/common/l2_biz/plugin/l2-plugin-management-messages.ts +52 -22
  48. package/src/common/l3_modules/conversation/l3-conversation-locale-messages.ts +2 -2
  49. package/src/common/l3_modules/conversation/l3-conversation-pidesk.ts +1 -1
  50. package/src/common/l4_foundation/plugin/l4-plugin-package.ts +1 -0
  51. package/src/server/l2_biz/plugin-management/l2-plugin-catalog.ts +16 -5
  52. package/src/server/l2_biz/plugin-management/l2-plugin-commands.ts +21 -1
  53. package/src/server/l2_biz/plugin-management/l2-plugin-management.ts +21 -2
  54. package/src/server/l2_biz/plugin-management/l2-plugin-updates.ts +17 -3
  55. package/src/server/l4_foundation/pi/l4-pi-basic-session-services.ts +3 -3
  56. package/src/server/l4_foundation/pi/l4-pi-package-maintenance-runner.mts +4 -11
  57. package/src/server/l4_foundation/pi/l4-pi-plugin-registry-config.ts +52 -34
  58. package/src/server/l4_foundation/pi/l4-pi-plugin-registry-network.ts +5 -3
  59. package/src/server/l4_foundation/pi/l4-pi-plugin-registry.ts +23 -8
  60. package/src/server/l4_foundation/pi/l4-pi-session-initialization.ts +2 -5
  61. package/src/server/l4_foundation/pidesk/l4-pidesk-command.ts +8 -1
  62. package/src/server/l4_foundation/pidesk/l4-pidesk-runtime.ts +24 -38
  63. package/src/server/l4_foundation/pidesk/l4-pidesk-system-prompt.ts +11 -0
  64. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  65. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  66. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +1 -1
  67. package/temp/build/pi-desk/release/.next/required-server-files.js +2 -2
  68. package/temp/build/pi-desk/release/.next/required-server-files.json +2 -2
  69. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  70. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  71. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +3 -3
  72. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +3 -3
  73. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +3 -3
  74. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  75. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  76. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  77. package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
  78. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  79. package/temp/build/pi-desk/release/.next/server/app/page.js +79 -79
  80. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  81. package/temp/build/pi-desk/release/.next/server/chunks/25406.js +8 -77
  82. package/temp/build/pi-desk/release/.next/server/chunks/{81107.js → 35348.js} +2 -2
  83. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +4 -4
  84. package/temp/build/pi-desk/release/.next/server/chunks/36361.js +1 -1
  85. package/temp/build/pi-desk/release/.next/server/chunks/47757.js +6 -75
  86. package/temp/build/pi-desk/release/.next/server/chunks/50520.js +4 -4
  87. package/temp/build/pi-desk/release/.next/server/chunks/58330.js +1 -1
  88. package/temp/build/pi-desk/release/.next/server/chunks/84231.js +2 -2
  89. package/temp/build/pi-desk/release/.next/server/chunks/89236.js +2 -2
  90. package/temp/build/pi-desk/release/.next/server/chunks/92386.js +5 -5
  91. package/temp/build/pi-desk/release/.next/server/chunks/94518.js +1 -1
  92. package/temp/build/pi-desk/release/.next/server/chunks/9874.js +1 -1
  93. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  94. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  95. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  96. package/temp/build/pi-desk/release/.next/static/chunks/14197-3d3ddd426ac06c9c.js +3 -0
  97. package/temp/build/pi-desk/release/.next/static/chunks/34903-e4205c0becabea76.js +318 -0
  98. package/temp/build/pi-desk/release/.next/static/chunks/{54206.7c0792096df8a5cf.js → 54206.82a63ab971911eda.js} +3 -3
  99. package/temp/build/pi-desk/release/.next/static/chunks/74132-876f256c7a6101af.js +13 -0
  100. package/temp/build/pi-desk/release/.next/static/chunks/78923-17a9c337b29dcecd.js +1 -0
  101. package/temp/build/pi-desk/release/.next/static/chunks/96995-8f8313ea2ac3abd7.js +62 -0
  102. package/temp/build/pi-desk/release/.next/static/chunks/app/{layout-7c5409cced9c2501.js → layout-4faf3a52d219296d.js} +2 -2
  103. package/temp/build/pi-desk/release/.next/static/chunks/app/login/{page-8c87fb9e6c918848.js → page-bf893718bb151f3c.js} +1 -1
  104. package/temp/build/pi-desk/release/.next/static/chunks/app/page-28e1d77d6b884657.js +158 -0
  105. package/temp/build/pi-desk/release/.next/static/chunks/{webpack-45424d28099bbefa.js → webpack-7bfbf627379fb4c1.js} +1 -1
  106. package/temp/build/pi-desk/release/.next/static/css/3213ee28336bddc2.css +1 -0
  107. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  108. package/src/server/l4_foundation/pidesk/l4-pidesk-skill-template.ts +0 -87
  109. package/temp/build/pi-desk/release/.next/static/chunks/27491-8b2d3e345b9142ea.js +0 -318
  110. package/temp/build/pi-desk/release/.next/static/chunks/7089-600a8f5593b7846c.js +0 -3
  111. package/temp/build/pi-desk/release/.next/static/chunks/74132-0884df6a6f11861a.js +0 -13
  112. package/temp/build/pi-desk/release/.next/static/chunks/78923-539a8b5f7af3c54a.js +0 -1
  113. package/temp/build/pi-desk/release/.next/static/chunks/96995-ae32167974365050.js +0 -62
  114. package/temp/build/pi-desk/release/.next/static/chunks/app/page-a85b2f17c68cff60.js +0 -158
  115. package/temp/build/pi-desk/release/.next/static/css/56486914a8ba0a1b.css +0 -1
  116. /package/temp/build/pi-desk/release/.next/static/{AIPx4Oo1zqM8gixtv1I69 → IgHJ40fniC07-ioYUKobL}/_buildManifest.js +0 -0
  117. /package/temp/build/pi-desk/release/.next/static/{AIPx4Oo1zqM8gixtv1I69 → IgHJ40fniC07-ioYUKobL}/_ssgManifest.js +0 -0
@@ -26,6 +26,7 @@ export type PluginManagementAction =
26
26
  | { kind: 'reload'; mode?: 'normal' | 'basic' }
27
27
 
28
28
  interface PluginManagementOptions {
29
+ basicMode?: boolean
29
30
  biz: L2PluginManagementBiz
30
31
  snapshot: L2PluginManagementSnapshot | null
31
32
  onSnapshot: (snapshot: L2PluginManagementSnapshot) => void
@@ -104,6 +105,7 @@ interface PluginManagementState {
104
105
  }
105
106
 
106
107
  export function useL2PluginManagement({
108
+ basicMode = false,
107
109
  biz,
108
110
  snapshot,
109
111
  onSnapshot,
@@ -172,21 +174,24 @@ export function useL2PluginManagement({
172
174
  const available = targets.filter((source) => !busySource(source))
173
175
  busy.forEach((source) => setError(pluginRequestKey(source), t('sourceBusy')))
174
176
  if (!available.length) return
175
- if (busy.length && available.length > 32) {
176
- setError('list', t('batchLimit'))
177
- return
178
- }
179
177
  const keys = available.map(pluginRequestKey)
180
178
  setRequest('check', true)
181
179
  keys.forEach((key) => setRequest(key, true))
182
180
  setLoading(true)
183
181
  setError('list', null)
184
182
  try {
185
- await readSnapshot({
186
- checkUpdates: true,
187
- ...(sources || busy.length ? { sources: available } : {}),
188
- ...(tag ? { tag } : {})
189
- })
183
+ if (sources || busy.length) {
184
+ for (let offset = 0; offset < available.length; offset += 32) {
185
+ await readSnapshot({
186
+ checkUpdates: true,
187
+ sources: available.slice(offset, offset + 32),
188
+ ...(tag ? { tag } : {})
189
+ })
190
+ if (!mounted.current) break
191
+ }
192
+ } else {
193
+ await readSnapshot({ checkUpdates: true, ...(tag ? { tag } : {}) })
194
+ }
190
195
  } catch (cause) {
191
196
  setError('list', cause instanceof Error ? cause.message : t('updateCheckFailed'))
192
197
  } finally {
@@ -204,8 +209,21 @@ export function useL2PluginManagement({
204
209
  setLoading(true)
205
210
  void biz
206
211
  .list(false)
207
- .then((next) => {
208
- if (active && request === listRequest.current) onSnapshot(next)
212
+ .then(async (next) => {
213
+ if (!active || request !== listRequest.current) return
214
+ onSnapshot(next)
215
+ const unchecked = next.plugins.some(
216
+ (plugin) =>
217
+ plugin.kind === 'package' &&
218
+ /^(npm:|git:|https?:|git@)/.test(plugin.source) &&
219
+ plugin.updateAvailable === null &&
220
+ !plugin.updateError &&
221
+ !plugin.operation
222
+ )
223
+ if (!basicMode && unchecked) {
224
+ const checked = await biz.list(true)
225
+ if (active && request === listRequest.current) onSnapshot(checked)
226
+ }
209
227
  })
210
228
  .catch((cause: unknown) => {
211
229
  if (active)
@@ -221,7 +239,7 @@ export function useL2PluginManagement({
221
239
  detailRequest.current += 1
222
240
  listRequest.current += 1
223
241
  }
224
- }, [biz, onSnapshot, t])
242
+ }, [biz, onSnapshot, t, basicMode])
225
243
 
226
244
  const executeBatch = async (input: L2PluginManagementBatchRequest): Promise<void> => {
227
245
  const sources = input.action === 'add' ? input.items.map((item) => item.source) : input.sources
@@ -236,19 +254,51 @@ export function useL2PluginManagement({
236
254
  setError(key, null)
237
255
  })
238
256
  setConfirmation(null)
257
+ let remaining = [...available]
239
258
  try {
240
- const request =
241
- input.action === 'add'
242
- ? { ...input, items: input.items.filter((item) => available.includes(item.source)) }
243
- : { ...input, sources: available }
244
- const response = await biz.batch(request)
245
- if (!mounted.current) return
246
- listRequest.current += 1
247
- onSnapshot(response.snapshot)
248
- response.results.forEach((result) => setError(pluginRequestKey(result.source), result.error))
259
+ let current = await biz.list()
260
+ const deadline = Date.now() + 15 * 60_000
261
+ while (remaining.length) {
262
+ const pending = current.plugins.filter(
263
+ (plugin) => plugin.operation && plugin.operation.phase !== 'failed'
264
+ )
265
+ const capacity = input.action === 'tag' ? 32 : Math.max(0, 32 - pending.length)
266
+ if (!capacity) {
267
+ if (
268
+ pending.some((plugin) => plugin.operation?.phase === 'waiting') ||
269
+ Date.now() >= deadline
270
+ ) {
271
+ remaining.forEach((source) => setError(pluginRequestKey(source), t('batchWaiting')))
272
+ break
273
+ }
274
+ await new Promise<void>((resolve) => setTimeout(resolve, 750))
275
+ current = await biz.list()
276
+ if (mounted.current) {
277
+ listRequest.current += 1
278
+ onSnapshot(current)
279
+ }
280
+ continue
281
+ }
282
+ const chunk = remaining.slice(0, capacity)
283
+ const request =
284
+ input.action === 'add'
285
+ ? { ...input, items: input.items.filter((item) => chunk.includes(item.source)) }
286
+ : { ...input, sources: chunk }
287
+ const response = await biz.batch(request)
288
+ current = response.snapshot
289
+ if (mounted.current) {
290
+ listRequest.current += 1
291
+ onSnapshot(current)
292
+ }
293
+ response.results.forEach((result) =>
294
+ setError(pluginRequestKey(result.source), result.error)
295
+ )
296
+ chunk.forEach((source) => setRequest(pluginRequestKey(source), false))
297
+ remaining = remaining.slice(chunk.length)
298
+ }
249
299
  } catch (cause) {
250
300
  const message = cause instanceof Error ? cause.message : t('batchFailed')
251
- keys.forEach((key) => setError(key, message))
301
+ remaining.forEach((source) => setError(pluginRequestKey(source), message))
252
302
  } finally {
253
303
  keys.forEach((key) => setRequest(key, false))
254
304
  }
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import { ChevronDownIcon } from 'lucide-react'
3
4
  import { useTranslation } from 'react-i18next'
4
5
  import type {
5
6
  L2PluginCatalogDetail,
@@ -8,8 +9,21 @@ import type {
8
9
  import { selectL4LocalizedText } from '@common/l4_foundation/locale/l4-localized-text'
9
10
  import { useL4Region } from '@client/l4_foundation/locale/l4-region-provider'
10
11
  import { Button } from '@client/l4_foundation/ui/shadcn/button'
12
+ import {
13
+ Collapsible,
14
+ CollapsibleContent,
15
+ CollapsibleTrigger
16
+ } from '@client/l4_foundation/ui/shadcn/collapsible'
17
+ import {
18
+ Select,
19
+ SelectContent,
20
+ SelectItem,
21
+ SelectTrigger,
22
+ SelectValue
23
+ } from '@client/l4_foundation/ui/shadcn/select'
11
24
  import { L2PluginReadme } from './l2-plugin-readme'
12
25
  import { L2PluginDownloadSourceSelector } from './l2-plugin-download-source'
26
+ import { L2PluginManagementChannel } from './l2-plugin-management-channel'
13
27
 
14
28
  interface CatalogDetailProps {
15
29
  detail: L2PluginCatalogDetail
@@ -20,6 +34,8 @@ interface CatalogDetailProps {
20
34
  updateAvailable: boolean
21
35
  error: string | null
22
36
  operationMessage?: string | null
37
+ channel: string
38
+ onChannel: (channel: string) => void
23
39
  downloadMode: L2PluginDownloadSource['mode'] | 'default'
24
40
  downloadRegistry: string
25
41
  onDownloadMode: (mode: CatalogDetailProps['downloadMode']) => void
@@ -46,60 +62,57 @@ export function L2PluginCatalogDetailView(props: CatalogDetailProps): React.JSX.
46
62
  const selectedVersionInstalled = props.installed && props.installedVersion === detail.version
47
63
  const homepage = safeLink(detail.homepage)
48
64
  const repository = safeLink(detail.repository)
65
+ const versions = [...new Set([detail.version, ...detail.versions])]
49
66
  return (
50
67
  <div className="min-w-0 space-y-5">
51
68
  <div className="space-y-3">
52
69
  <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
53
- {detail.official ? (
54
- <span className="rounded-md bg-secondary px-2 py-1 text-secondary-foreground">
55
- {t('official')}
56
- </span>
57
- ) : null}
70
+ <span>v{detail.version}</span>
58
71
  <span>{t(`kind_${detail.kind}`)}</span>
59
- <span className="[overflow-wrap:anywhere]">
60
- {detail.publisher ?? t('unknownPublisher')}
61
- </span>
72
+ {detail.publisher ? (
73
+ <span className="[overflow-wrap:anywhere]">{detail.publisher}</span>
74
+ ) : null}
62
75
  </div>
63
- <p className="text-sm text-muted-foreground [overflow-wrap:anywhere]">
64
- {detail.description
65
- ? selectL4LocalizedText(detail.description, locale)
66
- : t('noPluginDescription')}
67
- </p>
68
- <label className="flex flex-wrap items-center gap-2 text-sm">
69
- {t('version')}
70
- <select
71
- value={detail.version}
72
- onChange={(event) => props.onVersion(event.target.value)}
73
- className="min-h-8 min-w-0 max-w-full rounded-lg border border-input bg-background px-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
74
- >
75
- {[...new Set([detail.version, ...detail.versions])].map((version) => (
76
- <option key={version} value={version}>
77
- {version}
78
- </option>
79
- ))}
80
- </select>
81
- </label>
82
- <p className="text-sm text-muted-foreground">
83
- {t(
84
- detail.compatible === true
85
- ? 'compatible'
86
- : detail.compatible === false
87
- ? 'incompatible'
88
- : 'compatibilityUnknown'
89
- )}
90
- </p>
91
- {detail.compatibilityNote ? (
76
+ {detail.description ? (
92
77
  <p className="text-sm text-muted-foreground [overflow-wrap:anywhere]">
93
- {detail.compatibilityNote}
78
+ {selectL4LocalizedText(detail.description, locale)}
79
+ </p>
80
+ ) : null}
81
+ {detail.compatible === false ? (
82
+ <p role="alert" className="text-sm text-destructive">
83
+ {t('incompatible')}
84
+ {detail.compatibilityNote ? ` · ${detail.compatibilityNote}` : ''}
94
85
  </p>
95
86
  ) : null}
96
- <div className="flex flex-wrap gap-3 text-sm">
87
+ <div className="flex flex-wrap items-center gap-3">
88
+ {props.pending ? (
89
+ <span role="status" className="text-sm text-muted-foreground">
90
+ {props.operationMessage ?? t('submitting')}
91
+ </span>
92
+ ) : selectedVersionInstalled ? (
93
+ <>
94
+ <span className="text-sm text-muted-foreground">{t('alreadyInstalled')}</span>
95
+ {props.updateAvailable ? (
96
+ <Button size="sm" disabled={props.disabled} onClick={props.onUpdate}>
97
+ {t('update')}
98
+ </Button>
99
+ ) : null}
100
+ </>
101
+ ) : (
102
+ <Button
103
+ size="sm"
104
+ disabled={props.disabled || detail.compatible === false}
105
+ onClick={props.onInstall}
106
+ >
107
+ {t(props.installed ? 'installVersion' : 'install')}
108
+ </Button>
109
+ )}
97
110
  {homepage ? (
98
111
  <a
99
112
  href={homepage}
100
113
  target="_blank"
101
114
  rel="noopener noreferrer"
102
- className="underline underline-offset-4"
115
+ className="text-sm underline underline-offset-4"
103
116
  >
104
117
  {t('homepage')}
105
118
  </a>
@@ -109,53 +122,68 @@ export function L2PluginCatalogDetailView(props: CatalogDetailProps): React.JSX.
109
122
  href={repository}
110
123
  target="_blank"
111
124
  rel="noopener noreferrer"
112
- className="underline underline-offset-4"
125
+ className="text-sm underline underline-offset-4"
113
126
  >
114
127
  {t('repository')}
115
128
  </a>
116
129
  ) : null}
117
130
  </div>
118
- {!selectedVersionInstalled ? (
119
- <L2PluginDownloadSourceSelector
120
- id="plugin-detail-download"
121
- mode={props.downloadMode}
122
- registry={props.downloadRegistry}
123
- override
124
- onMode={props.onDownloadMode}
125
- onRegistry={props.onDownloadRegistry}
126
- />
127
- ) : null}
128
- <div className="flex flex-wrap items-center gap-2">
129
- {props.pending ? (
130
- <span role="status" className="text-sm text-muted-foreground">
131
- {props.operationMessage ?? t('submitting')}
132
- </span>
133
- ) : selectedVersionInstalled ? (
134
- <>
135
- <span className="text-sm text-muted-foreground">{t('alreadyInstalled')}</span>
136
- {props.updateAvailable ? (
137
- <Button
138
- size="sm"
139
- variant="outline"
140
- disabled={props.disabled}
141
- onClick={props.onUpdate}
142
- >
143
- {t('update')}
144
- </Button>
145
- ) : null}
146
- </>
147
- ) : (
148
- <Button size="sm" disabled={props.disabled} onClick={props.onInstall}>
149
- {t(props.installed ? 'installVersion' : 'install')}
150
- </Button>
151
- )}
152
- </div>
153
131
  {props.error ? (
154
132
  <p role="alert" className="text-sm text-destructive [overflow-wrap:anywhere]">
155
133
  {props.error}
156
134
  </p>
157
135
  ) : null}
158
136
  </div>
137
+ <Collapsible className="rounded-lg border">
138
+ <CollapsibleTrigger className="group flex min-h-9 w-full items-center justify-between px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring">
139
+ {t('advancedOptions')}
140
+ <ChevronDownIcon className="size-4 transition-transform group-aria-expanded:rotate-180" />
141
+ </CollapsibleTrigger>
142
+ <CollapsibleContent keepMounted className="hidden data-open:block">
143
+ <div className="space-y-3 px-3 pb-3">
144
+ <div className="flex flex-wrap items-center gap-2 text-sm">
145
+ <span>{t('installChannel')}</span>
146
+ <L2PluginManagementChannel
147
+ value={props.channel}
148
+ label={t('installChannel')}
149
+ placeholder={t('defaultChannel')}
150
+ disabled={props.pending}
151
+ onChange={props.onChannel}
152
+ />
153
+ <Select
154
+ value={detail.version}
155
+ items={versions.map((value) => ({ value, label: value }))}
156
+ onOpenChange={(open, details) => {
157
+ if (open && props.pending) details.cancel()
158
+ }}
159
+ onValueChange={(value) => {
160
+ if (value && !props.pending) props.onVersion(value)
161
+ }}
162
+ >
163
+ <SelectTrigger aria-label={t('version')} aria-disabled={props.pending}>
164
+ <SelectValue />
165
+ </SelectTrigger>
166
+ <SelectContent positionerClassName="z-[160]">
167
+ {versions.map((value) => (
168
+ <SelectItem key={value} value={value} disabled={props.pending}>
169
+ {value}
170
+ </SelectItem>
171
+ ))}
172
+ </SelectContent>
173
+ </Select>
174
+ </div>
175
+ <L2PluginDownloadSourceSelector
176
+ id="plugin-detail-download"
177
+ mode={props.downloadMode}
178
+ registry={props.downloadRegistry}
179
+ override
180
+ saving={props.pending}
181
+ onMode={props.onDownloadMode}
182
+ onRegistry={props.onDownloadRegistry}
183
+ />
184
+ </div>
185
+ </CollapsibleContent>
186
+ </Collapsible>
159
187
  <div className="border-t pt-4">
160
188
  <L2PluginReadme key={`${detail.name}@${detail.version}`} readme={detail.readme} />
161
189
  </div>
@@ -10,6 +10,13 @@ import { selectL4LocalizedText } from '@common/l4_foundation/locale/l4-localized
10
10
  import { useL4Region } from '@client/l4_foundation/locale/l4-region-provider'
11
11
  import { Button } from '@client/l4_foundation/ui/shadcn/button'
12
12
  import { Input } from '@client/l4_foundation/ui/shadcn/input'
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue
19
+ } from '@client/l4_foundation/ui/shadcn/select'
13
20
  import { pluginNpmSpec, pluginRequestKey } from '../l2-use-plugin-management'
14
21
 
15
22
  interface CatalogSearchProps {
@@ -39,6 +46,7 @@ interface CatalogSearchProps {
39
46
  onUpdate: (source: string) => void
40
47
  onMore: () => void
41
48
  onRetry: () => void
49
+ onDirectInstall: () => void
42
50
  }
43
51
 
44
52
  export function L2PluginCatalogSearch(props: CatalogSearchProps): React.JSX.Element {
@@ -51,7 +59,7 @@ export function L2PluginCatalogSearch(props: CatalogSearchProps): React.JSX.Elem
51
59
  )
52
60
  return (
53
61
  <section className="min-w-0 space-y-4" aria-label={t('searchPlugins')}>
54
- <div className="space-y-3">
62
+ <div className="flex flex-wrap items-center gap-2">
55
63
  <label htmlFor="plugin-catalog-query" className="sr-only">
56
64
  {t('searchPlugins')}
57
65
  </label>
@@ -59,40 +67,61 @@ export function L2PluginCatalogSearch(props: CatalogSearchProps): React.JSX.Elem
59
67
  id="plugin-catalog-query"
60
68
  value={props.query}
61
69
  placeholder={t('searchPlaceholder')}
70
+ className="min-w-40 flex-1 basis-56"
62
71
  maxLength={200}
63
72
  onChange={(event) => props.onQuery(event.target.value)}
64
73
  onCompositionStart={props.onCompositionStart}
65
74
  onCompositionEnd={props.onCompositionEnd}
66
75
  />
67
- <div className="flex flex-wrap gap-3">
68
- <label className="flex items-center gap-2 text-sm">
69
- {t('searchSource')}
70
- <select
71
- value={props.searchSource}
72
- onChange={(event) => props.onSearchSource(event.target.value as 'public' | 'custom')}
73
- className="min-h-8 rounded-lg border border-input bg-background px-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
74
- >
75
- <option value="public">{t('publicNpm')}</option>
76
- <option value="custom">{t('customSearchSource')}</option>
77
- </select>
78
- </label>
79
- <label className="flex items-center gap-2 text-sm">
80
- {t('pluginKind')}
81
- <select
82
- value={props.kind}
83
- onChange={(event) => props.onKind(event.target.value as CatalogSearchProps['kind'])}
84
- className="min-h-8 rounded-lg border border-input bg-background px-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
85
- >
86
- {(['all', 'desk', 'pi'] as const).map((kind) => (
87
- <option key={kind} value={kind}>
88
- {t(`kind_${kind}`)}
89
- </option>
90
- ))}
91
- </select>
92
- </label>
93
- </div>
76
+ <Select
77
+ value={props.searchSource}
78
+ items={[
79
+ { value: 'public', label: t('publicNpm') },
80
+ { value: 'custom', label: t('customSearchSource') }
81
+ ]}
82
+ onValueChange={(value) => {
83
+ if (value) props.onSearchSource(value)
84
+ }}
85
+ >
86
+ <SelectTrigger aria-label={t('searchSource')}>
87
+ <SelectValue />
88
+ </SelectTrigger>
89
+ <SelectContent positionerClassName="z-[160]">
90
+ <SelectItem value="public">{t('publicNpm')}</SelectItem>
91
+ <SelectItem value="custom">{t('customSearchSource')}</SelectItem>
92
+ </SelectContent>
93
+ </Select>
94
+ <Select
95
+ value={props.kind}
96
+ items={(['all', 'desk', 'pi'] as const).map((value) => ({
97
+ value,
98
+ label: t(`kind_${value}`)
99
+ }))}
100
+ onValueChange={(value) => {
101
+ if (value) props.onKind(value)
102
+ }}
103
+ >
104
+ <SelectTrigger aria-label={t('pluginKind')}>
105
+ <SelectValue />
106
+ </SelectTrigger>
107
+ <SelectContent positionerClassName="z-[160]">
108
+ {(['all', 'desk', 'pi'] as const).map((value) => (
109
+ <SelectItem key={value} value={value}>
110
+ {t(`kind_${value}`)}
111
+ </SelectItem>
112
+ ))}
113
+ </SelectContent>
114
+ </Select>
115
+ <Button
116
+ size="sm"
117
+ variant="outline"
118
+ disabled={props.disabled}
119
+ onClick={props.onDirectInstall}
120
+ >
121
+ {t('directInstall')}
122
+ </Button>
94
123
  {props.searchSource === 'custom' ? (
95
- <div className="space-y-1">
124
+ <div className="w-full space-y-1">
96
125
  <label htmlFor="plugin-search-registry" className="block text-sm">
97
126
  {t('searchRegistryUrl')}
98
127
  </label>
@@ -115,7 +144,7 @@ export function L2PluginCatalogSearch(props: CatalogSearchProps): React.JSX.Elem
115
144
  </div>
116
145
  ) : null}
117
146
  {props.items.length ? (
118
- <div className="divide-y rounded-lg border bg-card">
147
+ <div className="@container/catalog divide-y rounded-lg border bg-card">
119
148
  {props.items.map((item) => {
120
149
  const plugin = installed.get(item.name)
121
150
  const key = pluginRequestKey(`npm:${item.name}`)
@@ -129,32 +158,42 @@ export function L2PluginCatalogSearch(props: CatalogSearchProps): React.JSX.Elem
129
158
  props.errors[key] ??
130
159
  (plugin?.error ? selectL4LocalizedText(plugin.error.message, locale) : null)
131
160
  return (
132
- <article key={item.name} className="min-w-0 space-y-2 p-4">
133
- <div className="flex flex-wrap items-baseline gap-2">
134
- <Button
135
- variant="link"
136
- className="h-auto min-w-0 max-w-full p-0 text-left text-sm font-semibold whitespace-normal [overflow-wrap:anywhere]"
137
- onClick={() => props.onDetail(item)}
138
- >
139
- {item.name}
140
- </Button>
141
- <span className="text-xs text-muted-foreground">v{item.version}</span>
142
- {item.official ? (
143
- <span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
144
- {t('official')}
161
+ <article
162
+ key={item.name}
163
+ aria-label={item.name}
164
+ className="grid min-w-0 gap-x-4 gap-y-2 px-4 py-3 @xl/catalog:grid-cols-[minmax(0,1fr)_auto]"
165
+ >
166
+ <div className="min-w-0 space-y-1">
167
+ <div className="flex flex-wrap items-center gap-2">
168
+ <Button
169
+ variant="link"
170
+ className="h-auto min-h-8 min-w-0 max-w-full p-0 text-left text-sm font-semibold whitespace-normal no-underline hover:underline [overflow-wrap:anywhere]"
171
+ onClick={() => props.onDetail(item)}
172
+ >
173
+ {item.name}
174
+ </Button>
175
+ <span className="text-xs text-muted-foreground">
176
+ v{healthy && plugin.version ? plugin.version : item.version}
145
177
  </span>
146
- ) : null}
147
- <span className="text-xs text-muted-foreground">{t(`kind_${item.kind}`)}</span>
178
+ {healthy && plugin.updateAvailable ? (
179
+ <span className="rounded-md bg-status-success/10 px-2 py-0.5 text-xs font-medium text-status-success">
180
+ {plugin.availableVersion
181
+ ? t('newVersion', { version: plugin.availableVersion })
182
+ : t('updateAvailable')}
183
+ </span>
184
+ ) : null}
185
+ <span className="text-xs text-muted-foreground">{t(`kind_${item.kind}`)}</span>
186
+ {item.publisher ? (
187
+ <span className="text-xs text-muted-foreground">{item.publisher}</span>
188
+ ) : null}
189
+ </div>
190
+ <p className="line-clamp-2 text-sm text-muted-foreground [overflow-wrap:anywhere]">
191
+ {description
192
+ ? selectL4LocalizedText(description, locale)
193
+ : t('noPluginDescription')}
194
+ </p>
148
195
  </div>
149
- <p className="text-sm text-muted-foreground [overflow-wrap:anywhere]">
150
- {description
151
- ? selectL4LocalizedText(description, locale)
152
- : t('noPluginDescription')}
153
- </p>
154
- <div className="flex flex-wrap items-center justify-between gap-2">
155
- <span className="min-w-0 text-xs text-muted-foreground [overflow-wrap:anywhere]">
156
- {item.publisher ?? t('unknownPublisher')}
157
- </span>
196
+ <div className="flex min-h-8 items-center self-start @xl/catalog:justify-end">
158
197
  {pending ? (
159
198
  <span role="status" className="text-sm text-muted-foreground">
160
199
  {unfinished
@@ -189,12 +228,18 @@ export function L2PluginCatalogSearch(props: CatalogSearchProps): React.JSX.Elem
189
228
  )}
190
229
  </div>
191
230
  {error ? (
192
- <p role="alert" className="text-sm text-destructive [overflow-wrap:anywhere]">
231
+ <p
232
+ role="alert"
233
+ className="text-sm text-destructive [overflow-wrap:anywhere] @xl/catalog:col-span-2"
234
+ >
193
235
  {error}
194
236
  </p>
195
237
  ) : null}
196
238
  {plugin?.operation?.phase === 'failed' && plugin.operation.message ? (
197
- <p role="alert" className="text-sm text-destructive [overflow-wrap:anywhere]">
239
+ <p
240
+ role="alert"
241
+ className="text-sm text-destructive [overflow-wrap:anywhere] @xl/catalog:col-span-2"
242
+ >
198
243
  {plugin.operation.message}
199
244
  </p>
200
245
  ) : null}