@mohou/panel 1.0.20

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 (113) hide show
  1. package/README.md +8 -0
  2. package/lib/types/chrome/hue.d.ts +3 -0
  3. package/lib/types/chrome/hue.d.ts.map +1 -0
  4. package/lib/types/gallery/card-style.d.ts +8 -0
  5. package/lib/types/gallery/card-style.d.ts.map +1 -0
  6. package/lib/types/gallery/client.d.ts +20 -0
  7. package/lib/types/gallery/client.d.ts.map +1 -0
  8. package/lib/types/gallery/desk.d.ts +16 -0
  9. package/lib/types/gallery/desk.d.ts.map +1 -0
  10. package/lib/types/gallery/list.d.ts +37 -0
  11. package/lib/types/gallery/list.d.ts.map +1 -0
  12. package/lib/types/gallery/sort.d.ts +24 -0
  13. package/lib/types/gallery/sort.d.ts.map +1 -0
  14. package/lib/types/gallery/state.d.ts +90 -0
  15. package/lib/types/gallery/state.d.ts.map +1 -0
  16. package/lib/types/gallery/tabs.d.ts +45 -0
  17. package/lib/types/gallery/tabs.d.ts.map +1 -0
  18. package/lib/types/gallery/view.d.ts +57 -0
  19. package/lib/types/gallery/view.d.ts.map +1 -0
  20. package/lib/types/history/client.d.ts +25 -0
  21. package/lib/types/history/client.d.ts.map +1 -0
  22. package/lib/types/history/state.d.ts +39 -0
  23. package/lib/types/history/state.d.ts.map +1 -0
  24. package/lib/types/history/view.d.ts +15 -0
  25. package/lib/types/history/view.d.ts.map +1 -0
  26. package/lib/types/index.d.ts +24 -0
  27. package/lib/types/index.d.ts.map +1 -0
  28. package/lib/types/labels.d.ts +267 -0
  29. package/lib/types/labels.d.ts.map +1 -0
  30. package/lib/types/settings/author-mcp-view.d.ts +15 -0
  31. package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
  32. package/lib/types/settings/author-mcp.d.ts +4 -0
  33. package/lib/types/settings/author-mcp.d.ts.map +1 -0
  34. package/lib/types/settings/client.d.ts +198 -0
  35. package/lib/types/settings/client.d.ts.map +1 -0
  36. package/lib/types/settings/credentials-view.d.ts +15 -0
  37. package/lib/types/settings/credentials-view.d.ts.map +1 -0
  38. package/lib/types/settings/form.d.ts +27 -0
  39. package/lib/types/settings/form.d.ts.map +1 -0
  40. package/lib/types/settings/install-row.d.ts +10 -0
  41. package/lib/types/settings/install-row.d.ts.map +1 -0
  42. package/lib/types/settings/mcp-view.d.ts +14 -0
  43. package/lib/types/settings/mcp-view.d.ts.map +1 -0
  44. package/lib/types/settings/skill-dirs.d.ts +6 -0
  45. package/lib/types/settings/skill-dirs.d.ts.map +1 -0
  46. package/lib/types/settings/skill-install.d.ts +13 -0
  47. package/lib/types/settings/skill-install.d.ts.map +1 -0
  48. package/lib/types/settings/state.d.ts +94 -0
  49. package/lib/types/settings/state.d.ts.map +1 -0
  50. package/lib/types/settings/update-dialog.d.ts +56 -0
  51. package/lib/types/settings/update-dialog.d.ts.map +1 -0
  52. package/lib/types/settings/view.d.ts +26 -0
  53. package/lib/types/settings/view.d.ts.map +1 -0
  54. package/lib/types/storage/client.d.ts +15 -0
  55. package/lib/types/storage/client.d.ts.map +1 -0
  56. package/lib/types/storage/state.d.ts +42 -0
  57. package/lib/types/storage/state.d.ts.map +1 -0
  58. package/lib/types/storage/view.d.ts +16 -0
  59. package/lib/types/storage/view.d.ts.map +1 -0
  60. package/lib/types/surface/state.d.ts +42 -0
  61. package/lib/types/surface/state.d.ts.map +1 -0
  62. package/lib/types/surface/view.d.ts +34 -0
  63. package/lib/types/surface/view.d.ts.map +1 -0
  64. package/lib/types/theme/client.d.ts +45 -0
  65. package/lib/types/theme/client.d.ts.map +1 -0
  66. package/lib/types/theme/menu.d.ts +25 -0
  67. package/lib/types/theme/menu.d.ts.map +1 -0
  68. package/lib/types/theme/state.d.ts +46 -0
  69. package/lib/types/theme/state.d.ts.map +1 -0
  70. package/lib/types/theme/view.d.ts +16 -0
  71. package/lib/types/theme/view.d.ts.map +1 -0
  72. package/lib/types/ui/dialog.d.ts +12 -0
  73. package/lib/types/ui/dialog.d.ts.map +1 -0
  74. package/lib/types/ui/tooltip.d.ts +12 -0
  75. package/lib/types/ui/tooltip.d.ts.map +1 -0
  76. package/package.json +38 -0
  77. package/src/chrome/hue.ts +6 -0
  78. package/src/gallery/card-style.ts +27 -0
  79. package/src/gallery/client.ts +26 -0
  80. package/src/gallery/desk.tsx +54 -0
  81. package/src/gallery/list.ts +54 -0
  82. package/src/gallery/sort.ts +72 -0
  83. package/src/gallery/state.ts +229 -0
  84. package/src/gallery/tabs.ts +85 -0
  85. package/src/gallery/view.tsx +501 -0
  86. package/src/history/client.ts +27 -0
  87. package/src/history/state.ts +90 -0
  88. package/src/history/view.tsx +131 -0
  89. package/src/index.ts +61 -0
  90. package/src/labels.ts +536 -0
  91. package/src/settings/author-mcp-view.tsx +154 -0
  92. package/src/settings/author-mcp.ts +24 -0
  93. package/src/settings/client.ts +205 -0
  94. package/src/settings/credentials-view.tsx +195 -0
  95. package/src/settings/form.ts +27 -0
  96. package/src/settings/install-row.tsx +35 -0
  97. package/src/settings/mcp-view.tsx +793 -0
  98. package/src/settings/skill-dirs.ts +41 -0
  99. package/src/settings/skill-install.tsx +134 -0
  100. package/src/settings/state.ts +225 -0
  101. package/src/settings/update-dialog.tsx +207 -0
  102. package/src/settings/view.tsx +722 -0
  103. package/src/storage/client.ts +16 -0
  104. package/src/storage/state.ts +94 -0
  105. package/src/storage/view.tsx +190 -0
  106. package/src/surface/state.ts +72 -0
  107. package/src/surface/view.tsx +255 -0
  108. package/src/theme/client.ts +34 -0
  109. package/src/theme/menu.tsx +286 -0
  110. package/src/theme/state.ts +113 -0
  111. package/src/theme/view.tsx +73 -0
  112. package/src/ui/dialog.tsx +25 -0
  113. package/src/ui/tooltip.tsx +19 -0
@@ -0,0 +1,41 @@
1
+ const customKey = 'mini-app.panel.skill-dirs'
2
+ const selectedKey = 'mini-app.panel.skill-agents'
3
+
4
+ /** Custom `.../skills` directories remembered in this browser. */
5
+ export function readSkillCustomDirs(): string[] {
6
+ return readList(customKey)
7
+ }
8
+
9
+ export function writeSkillCustomDirs(dirs: readonly string[]): void {
10
+ writeList(customKey, dirs)
11
+ }
12
+
13
+ export function readSkillAgentIds(): string[] | undefined {
14
+ const rows = readList(selectedKey)
15
+ return rows.length === 0 ? undefined : rows
16
+ }
17
+
18
+ export function writeSkillAgentIds(ids: readonly string[]): void {
19
+ writeList(selectedKey, ids)
20
+ }
21
+
22
+ function readList(key: string): string[] {
23
+ if (typeof localStorage === 'undefined') return []
24
+ try {
25
+ const raw = localStorage.getItem(key)
26
+ if (raw === null || raw.length === 0) return []
27
+ const parsed = JSON.parse(raw) as unknown
28
+ if (!Array.isArray(parsed)) return []
29
+ return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
30
+ } catch {
31
+ return []
32
+ }
33
+ }
34
+
35
+ function writeList(key: string, dirs: readonly string[]): void {
36
+ try {
37
+ localStorage.setItem(key, JSON.stringify([...dirs]))
38
+ } catch {
39
+ // A failed write still changes the current view.
40
+ }
41
+ }
@@ -0,0 +1,134 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+
3
+ import { Tooltip } from '../ui/tooltip.tsx'
4
+ import type { PanelSkillCopy, PanelSkillStatus } from './client.ts'
5
+ import { endsWithSkills, installingCaption, shortHomePath, toggleId } from './install-row.tsx'
6
+ import { readSkillAgentIds, readSkillCustomDirs, writeSkillAgentIds, writeSkillCustomDirs } from './skill-dirs.ts'
7
+
8
+ /**
9
+ * Writing-skill dest list. One-click copy into the assistants Shell named.
10
+ * @param props - status loaders and chrome labels
11
+ */
12
+ export function SkillInstall(props: {
13
+ readonly label: (key: string) => string
14
+ readonly readSkill?: (customDirs?: readonly string[]) => Promise<PanelSkillStatus>
15
+ readonly installSkill?: (agentIds: readonly string[], customDirs: readonly string[]) => Promise<PanelSkillStatus>
16
+ readonly revealSkill?: (dest: string) => Promise<void>
17
+ }): ReactNode {
18
+ const [skill, setSkill] = useState<PanelSkillStatus | undefined>(undefined)
19
+ const [busy, setBusy] = useState(false)
20
+ const [failed, setFailed] = useState(false)
21
+ const [picked, setPicked] = useState<ReadonlySet<string>>(() => new Set(readSkillAgentIds() ?? []))
22
+ const [customDirs, setCustomDirs] = useState<readonly string[]>(() => readSkillCustomDirs())
23
+ const [customDraft, setCustomDraft] = useState('')
24
+ const [customError, setCustomError] = useState(false)
25
+ useEffect(() => {
26
+ if (props.readSkill === undefined) return
27
+ void props.readSkill(customDirs).then((status) => {
28
+ setSkill(status)
29
+ if (readSkillAgentIds() === undefined) {
30
+ setPicked(new Set(status.agents.filter(agent => agent.homePresent).map(agent => agent.id)))
31
+ }
32
+ }, () => setSkill(undefined))
33
+ }, [props.readSkill, customDirs])
34
+ if (props.readSkill === undefined) return null
35
+ return (
36
+ <div className="rounded-2xl border bg-card px-4 py-3">
37
+ <p className="m-0 text-sm font-medium">{props.label('skill-install')}</p>
38
+ <p className="mt-1 mb-3 text-xs text-muted-foreground">
39
+ {props.label('skill-help')}
40
+ {skill?.version === null || skill?.version === undefined ? null : ` ${props.label('skill-version').replace('{n}', skill.version)}`}
41
+ </p>
42
+ <ul className="m-0 flex list-none flex-col gap-1 p-0">
43
+ {(skill?.agents ?? []).map(agent => (
44
+ <li key={agent.id} className={agent.homePresent ? 'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-muted' : 'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 opacity-50 hover:bg-muted'}>
45
+ <input type="checkbox" checked={picked.has(agent.id)} onChange={() => {
46
+ const next = toggleId(picked, agent.id)
47
+ setPicked(next)
48
+ writeSkillAgentIds([...next])
49
+ }} />
50
+ <span className="text-sm">{agent.label}</span>
51
+ <span className="ml-4 flex min-w-0 items-center gap-2">
52
+ {agent.homePresent ? null : <span className="text-[11px] text-muted-foreground">{props.label('skill-missing-home')}</span>}
53
+ {busy && picked.has(agent.id) ? installingCaption(props.label) : skillCopyCaption(agent, props.label)}
54
+ {agent.installed && props.revealSkill !== undefined ? (
55
+ <Tooltip text={agent.dest}><button type="button" className="max-w-[28rem] truncate font-mono text-[11px] text-muted-foreground underline-offset-2 hover:underline" onClick={() => { void props.revealSkill?.(agent.dest) }}>{shortHomePath(agent.dest)}</button></Tooltip>
56
+ ) : null}
57
+ </span>
58
+ </li>
59
+ ))}
60
+ </ul>
61
+ <div className="mt-3">
62
+ <p className="mb-1 text-xs text-muted-foreground">{props.label('skill-custom')}</p>
63
+ <div className="flex gap-2">
64
+ <input className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none" placeholder={props.label('skill-custom-hint')} value={customDraft} onChange={(event) => { setCustomDraft(event.target.value); setCustomError(false) }} />
65
+ <button type="button" className="h-9 rounded-lg border px-3 text-sm" onClick={() => {
66
+ const dir = customDraft.trim()
67
+ if (!endsWithSkills(dir)) {
68
+ setCustomError(true)
69
+ return
70
+ }
71
+ if (customDirs.includes(dir)) {
72
+ setCustomDraft('')
73
+ return
74
+ }
75
+ const next = [...customDirs, dir]
76
+ setCustomDirs(next)
77
+ writeSkillCustomDirs(next)
78
+ setCustomDraft('')
79
+ setCustomError(false)
80
+ }}>{props.label('skill-add-dir')}</button>
81
+ </div>
82
+ {customError ? <p className="mt-1 mb-0 text-xs text-destructive">{props.label('skill-custom-format')}</p> : null}
83
+ <ul className="mt-2 flex list-none flex-col gap-1 p-0">
84
+ {customDirs.map((dir) => {
85
+ const row = skill?.customs.find(item => item.dir === dir || item.dir.endsWith(dir.replace(/^~/, '')))
86
+ return (
87
+ <li key={dir} className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-muted">
88
+ <span className="max-w-[28rem] truncate font-mono text-xs">{dir}</span>
89
+ {busy ? installingCaption(props.label) : row === undefined ? null : skillCopyCaption(row, props.label)}
90
+ {row?.installed === true && props.revealSkill !== undefined ? (
91
+ <Tooltip text={row.dest}><button type="button" className="max-w-[28rem] truncate font-mono text-[11px] text-muted-foreground underline-offset-2 hover:underline" onClick={() => { void props.revealSkill?.(row.dest) }}>{shortHomePath(row.dest)}</button></Tooltip>
92
+ ) : null}
93
+ <button type="button" className="text-[11px] text-muted-foreground" onClick={() => {
94
+ const next = customDirs.filter(item => item !== dir)
95
+ setCustomDirs(next)
96
+ writeSkillCustomDirs(next)
97
+ }}>×</button>
98
+ </li>
99
+ )
100
+ })}
101
+ </ul>
102
+ </div>
103
+ <button type="button" className="mt-3 h-8 rounded-lg border px-3 text-sm hover:bg-muted disabled:opacity-60" disabled={busy || props.installSkill === undefined} onClick={() => {
104
+ const install = props.installSkill
105
+ if (install === undefined) return
106
+ setBusy(true)
107
+ setFailed(false)
108
+ void install([...picked], customDirs).then(setSkill, () => setFailed(true)).finally(() => setBusy(false))
109
+ }}>{busy ? '…' : props.label(skillNeedsUpdate(skill, picked, customDirs) ? 'skill-update-install' : 'skill-install')}</button>
110
+ {failed ? <p className="mt-2 mb-0 text-xs text-destructive">{props.label('skill-failed')}</p> : null}
111
+ </div>
112
+ )
113
+ }
114
+
115
+ function skillCopyCaption(row: PanelSkillCopy, label: (key: string) => string): ReactNode {
116
+ if (!row.installed) return null
117
+ return (
118
+ <>
119
+ <span className={row.updateAvailable ? 'text-[11px] text-primary' : 'text-[11px] text-muted-foreground'}>
120
+ {label(row.updateAvailable ? 'skill-update' : 'skill-installed')}
121
+ </span>
122
+ {row.version === null ? null : <span className="text-[11px] text-muted-foreground">{row.version}</span>}
123
+ </>
124
+ )
125
+ }
126
+
127
+ function skillNeedsUpdate(skill: PanelSkillStatus | undefined, picked: ReadonlySet<string>, customDirs: readonly string[]): boolean {
128
+ if (skill === undefined) return false
129
+ if (skill.agents.some(agent => picked.has(agent.id) && agent.updateAvailable)) return true
130
+ return customDirs.some((dir) => {
131
+ const row = skill.customs.find(item => item.dir === dir || item.dir.endsWith(dir.replace(/^~/, '')))
132
+ return row?.updateAvailable === true
133
+ })
134
+ }
@@ -0,0 +1,225 @@
1
+ import { admitPanelPort, panelLanguageFields } from './form.ts'
2
+ import type { PanelPolicy, PanelPolicyWrite, PanelSettingsClient } from './client.ts'
3
+
4
+ export interface SettingsState {
5
+ readonly saved?: PanelPolicy
6
+ readonly theme: PanelPolicy['theme']
7
+ readonly palette: string
8
+ readonly locale: string
9
+ readonly port: string
10
+ readonly providerId: string
11
+ readonly modelProvider: string
12
+ readonly model: string
13
+ readonly workbenchId: string
14
+ readonly dirty: boolean
15
+ readonly portError: boolean
16
+ readonly saveError: boolean
17
+ readonly saveMessage?: string
18
+ readonly pending?: 'close' | 'restore'
19
+ readonly closed: boolean
20
+ readonly restartRequired: boolean
21
+ /** True when restart is required because hostPort changed (MCP URL must be refreshed). */
22
+ readonly restartPort: boolean
23
+ /** True when restart is required because runtimeProvider.id changed. */
24
+ readonly restartRuntime: boolean
25
+ readonly probe?: string
26
+ }
27
+
28
+ export type SettingsAction =
29
+ | { readonly type: 'loaded'; readonly policy: PanelPolicy }
30
+ | { readonly type: 'edit-theme'; readonly theme: PanelPolicy['theme'] }
31
+ | { readonly type: 'edit-palette'; readonly palette: string }
32
+ | { readonly type: 'edit-locale'; readonly locale: string }
33
+ | { readonly type: 'edit-port'; readonly port: string }
34
+ | { readonly type: 'edit-provider'; readonly providerId: string }
35
+ | { readonly type: 'edit-model'; readonly provider: string; readonly model: string }
36
+ | { readonly type: 'edit-workbench'; readonly workbenchId: string }
37
+ | { readonly type: 'saved'; readonly result: PanelPolicyWrite }
38
+ /** Host policy written elsewhere (theme menu). Keeps appearance fields in sync. */
39
+ | { readonly type: 'host-policy'; readonly policy: PanelPolicy }
40
+ | { readonly type: 'port-invalid' }
41
+ | { readonly type: 'save-failed'; readonly message?: string }
42
+ | { readonly type: 'escape' }
43
+ | { readonly type: 'restore' }
44
+ | { readonly type: 'confirm' }
45
+ | { readonly type: 'probed'; readonly message: string }
46
+ | { readonly type: 'update-failed' }
47
+
48
+ /** Empty form before the policy load. */
49
+ export function settingsState(): SettingsState {
50
+ return {
51
+ theme: 'system',
52
+ palette: '',
53
+ locale: 'en',
54
+ port: '',
55
+ providerId: '',
56
+ modelProvider: '',
57
+ model: '',
58
+ workbenchId: 'default',
59
+ dirty: false,
60
+ portError: false,
61
+ saveError: false,
62
+ closed: false,
63
+ restartRequired: false,
64
+ restartPort: false,
65
+ restartRuntime: false,
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Settings form transition. Port and language checks happen before a write.
71
+ * Escape cancels a confirm first, then closes a clean form.
72
+ * A dirty form asks before close and before restore.
73
+ * @param state - current form
74
+ * @param action - one edit or client result
75
+ */
76
+ export function reduceSettings(state: SettingsState, action: SettingsAction): SettingsState {
77
+ switch (action.type) {
78
+ case 'loaded':
79
+ return fromPolicy(action.policy)
80
+ case 'edit-theme':
81
+ return { ...state, theme: action.theme, dirty: true, saveError: false }
82
+ case 'edit-palette':
83
+ return { ...state, palette: action.palette, dirty: true, saveError: false }
84
+ case 'edit-locale':
85
+ return { ...state, locale: action.locale, dirty: true, saveError: false }
86
+ case 'edit-port':
87
+ return { ...state, port: action.port, dirty: true, portError: false, saveError: false }
88
+ case 'edit-provider':
89
+ return { ...state, providerId: action.providerId, dirty: true, saveError: false }
90
+ case 'edit-model':
91
+ return { ...state, modelProvider: action.provider, model: action.model, dirty: true, saveError: false }
92
+ case 'edit-workbench':
93
+ return { ...state, workbenchId: action.workbenchId, dirty: true, saveError: false }
94
+ case 'saved': {
95
+ const portChanged = state.saved !== undefined && state.saved.hostPort !== action.result.policy.hostPort
96
+ const runtimeChanged = state.saved !== undefined
97
+ && state.saved.runtimeProvider.id !== action.result.policy.runtimeProvider.id
98
+ return {
99
+ ...fromPolicy(action.result.policy),
100
+ restartRequired: action.result.restartRequired,
101
+ restartPort: action.result.restartRequired && portChanged,
102
+ restartRuntime: action.result.restartRequired && runtimeChanged,
103
+ }
104
+ }
105
+ case 'host-policy': {
106
+ const policy = action.policy
107
+ if (!state.dirty) {
108
+ const next = fromPolicy(policy)
109
+ // Save updates parent hostPolicy immediately; do not drop the restart banner.
110
+ if (!state.restartRequired) return next
111
+ return {
112
+ ...next,
113
+ restartRequired: state.restartRequired,
114
+ restartPort: state.restartPort,
115
+ restartRuntime: state.restartRuntime,
116
+ }
117
+ }
118
+ return {
119
+ ...state,
120
+ theme: policy.theme,
121
+ palette: policy.palette,
122
+ saved: state.saved === undefined
123
+ ? policy
124
+ : { ...state.saved, theme: policy.theme, palette: policy.palette },
125
+ }
126
+ }
127
+ case 'port-invalid':
128
+ return { ...state, portError: true, dirty: true }
129
+ case 'save-failed':
130
+ return {
131
+ ...omitSaveMessage(state),
132
+ saveError: true,
133
+ dirty: true,
134
+ ...action.message ? { saveMessage: action.message } : {},
135
+ }
136
+ case 'escape':
137
+ if (state.pending !== undefined) return omitPending(state)
138
+ if (!state.dirty) return { ...state, closed: true }
139
+ return { ...state, pending: 'close' }
140
+ case 'restore':
141
+ if (!state.dirty) return state.saved === undefined ? state : fromPolicy(state.saved)
142
+ if (state.pending === 'restore') return state.saved === undefined ? omitPending(state) : fromPolicy(state.saved)
143
+ return { ...state, pending: 'restore' }
144
+ case 'confirm':
145
+ if (state.pending === 'restore') return state.saved === undefined ? omitPending(state) : fromPolicy(state.saved)
146
+ // Abandon close: restore the snapshot so previews can be discarded.
147
+ if (state.pending === 'close') {
148
+ if (state.saved === undefined) return { ...omitPending(state), closed: true }
149
+ return { ...fromPolicy(state.saved), closed: true }
150
+ }
151
+ return state
152
+ case 'probed':
153
+ return { ...state, probe: action.message }
154
+ case 'update-failed':
155
+ return { ...state, probe: 'update-failed' }
156
+ default:
157
+ return action satisfies never
158
+ }
159
+ }
160
+
161
+ /** Policy to write, or a port error that must not be sent. */
162
+ export function settingsDraft(state: SettingsState): { ok: true; policy: PanelPolicy } | { ok: false } {
163
+ const port = admitPanelPort(Number(state.port))
164
+ if (!port.ok) return { ok: false }
165
+ let language: ReturnType<typeof panelLanguageFields>
166
+ try {
167
+ language = panelLanguageFields(state.locale)
168
+ } catch {
169
+ return { ok: false }
170
+ }
171
+ return {
172
+ ok: true,
173
+ policy: {
174
+ theme: state.theme,
175
+ palette: state.palette,
176
+ locale: language.locale,
177
+ chatLanguage: language.chatLanguage,
178
+ hostPort: port.port,
179
+ llm: state.modelProvider === '' && state.model === '' ? null : { provider: state.modelProvider, model: state.model },
180
+ runtimeProvider: { id: state.providerId },
181
+ ...state.workbenchId === 'default' ? {} : { defaultWorkbenchId: state.workbenchId },
182
+ },
183
+ }
184
+ }
185
+
186
+ /** Load policy into the form. */
187
+ export async function loadSettings(
188
+ client: PanelSettingsClient,
189
+ dispatch: (action: SettingsAction) => void,
190
+ ): Promise<void> {
191
+ dispatch({ type: 'loaded', policy: await client.readPolicy() })
192
+ }
193
+
194
+ function fromPolicy(policy: PanelPolicy): SettingsState {
195
+ return {
196
+ saved: policy,
197
+ theme: policy.theme,
198
+ palette: policy.palette,
199
+ locale: policy.locale,
200
+ port: String(policy.hostPort),
201
+ providerId: policy.runtimeProvider.id,
202
+ modelProvider: policy.llm?.provider ?? '',
203
+ model: policy.llm?.model ?? '',
204
+ workbenchId: policy.defaultWorkbenchId ?? 'default',
205
+ dirty: false,
206
+ portError: false,
207
+ saveError: false,
208
+ closed: false,
209
+ restartRequired: false,
210
+ restartPort: false,
211
+ restartRuntime: false,
212
+ }
213
+ }
214
+
215
+ function omitPending(state: SettingsState): Omit<SettingsState, 'pending'> {
216
+ const { pending: gone, ...rest } = state
217
+ void gone
218
+ return rest
219
+ }
220
+
221
+ function omitSaveMessage(state: SettingsState): Omit<SettingsState, 'saveMessage'> {
222
+ const { saveMessage: gone, ...rest } = state
223
+ void gone
224
+ return rest
225
+ }
@@ -0,0 +1,207 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import { Loader2 } from 'lucide-react'
3
+
4
+ import { panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
5
+ import { Dialog } from '../ui/dialog.tsx'
6
+
7
+ import type { PanelUpdateAttempt, PanelUpdateFailureCode } from './client.ts'
8
+
9
+ /** The one update card. The on-open check and the settings check open this card. */
10
+ export type UpdateCard =
11
+ | { readonly kind: 'offer'; readonly current: string; readonly latest: string; readonly channel?: 'registry' | 'tarball' }
12
+ | { readonly kind: 'installing'; readonly version: string; readonly startedAt: number }
13
+ | { readonly kind: 'failed'; readonly reason: UpdateReason; readonly target?: string; readonly back?: string; readonly log?: string }
14
+ | { readonly kind: 'done'; readonly version: string }
15
+
16
+ /** A request that never reached the launcher carries its own text. A recorded outcome carries a closed code. */
17
+ export type UpdateReason =
18
+ | { readonly code: 'timeout' }
19
+ | { readonly code: 'exit'; readonly exitCode?: number }
20
+ | { readonly code: 'prepare' }
21
+ | { readonly code: 'closed' }
22
+ | { readonly code: 'leftover' }
23
+ | { readonly code: 'verify' }
24
+ | { readonly code: 'boot' }
25
+ | { readonly text: string }
26
+
27
+ const reasonKeys = {
28
+ timeout: 'update-reason-timeout',
29
+ exit: 'update-reason-exit',
30
+ prepare: 'update-reason-prepare',
31
+ closed: 'update-reason-closed',
32
+ leftover: 'update-reason-leftover',
33
+ verify: 'update-reason-verify',
34
+ boot: 'update-reason-boot',
35
+ } as const satisfies Record<PanelUpdateFailureCode, string>
36
+
37
+ const channelKeys = {
38
+ tarball: 'update-channel-tarball',
39
+ registry: 'update-channel-registry',
40
+ } as const
41
+
42
+ /** What the launcher recorded, as the card shows it. `current` is the version running now. */
43
+ export function attemptCard(attempt: PanelUpdateAttempt, current: string): UpdateCard {
44
+ if (attempt.state === 'done') return { kind: 'done', version: attempt.to ?? current }
45
+ return {
46
+ kind: 'failed',
47
+ reason: attempt.code === 'exit'
48
+ ? { code: 'exit', ...attempt.exitCode === undefined ? {} : { exitCode: attempt.exitCode } }
49
+ : { code: attempt.code },
50
+ ...attempt.to === undefined ? {} : { target: attempt.to },
51
+ ...attempt.rolledBack && attempt.from !== undefined ? { back: attempt.from } : {},
52
+ ...attempt.log === undefined ? {} : { log: attempt.log },
53
+ }
54
+ }
55
+
56
+ /**
57
+ * One update card: offer, install in progress, failure, or the version now running.
58
+ * @param props - card state and the two actions the card offers
59
+ */
60
+ export function UpdateDialog(props: {
61
+ readonly locale: PanelLocale
62
+ readonly mode: PanelLabelMode
63
+ readonly card: UpdateCard
64
+ readonly onClose: () => void
65
+ readonly onInstall: (version: string) => void
66
+ }): ReactNode {
67
+ const label = (key: string) => panelText(props.locale, key, props.mode)
68
+ return (
69
+ <div className="fixed inset-0 z-50">
70
+ <Dialog width="md" onClose={props.card.kind === 'installing' ? () => undefined : props.onClose}>
71
+ {props.card.kind === 'offer' ? <Offer card={props.card} label={label} onClose={props.onClose} onInstall={props.onInstall} /> : null}
72
+ {props.card.kind === 'installing' ? <Installing card={props.card} label={label} /> : null}
73
+ {props.card.kind === 'failed' ? <Failed card={props.card} label={label} onClose={props.onClose} onInstall={props.onInstall} /> : null}
74
+ {props.card.kind === 'done' ? <Done card={props.card} label={label} onClose={props.onClose} /> : null}
75
+ </Dialog>
76
+ </div>
77
+ )
78
+ }
79
+
80
+ function Offer(props: {
81
+ readonly card: Extract<UpdateCard, { kind: 'offer' }>
82
+ readonly label: (key: string) => string
83
+ readonly onClose: () => void
84
+ readonly onInstall: (version: string) => void
85
+ }): ReactNode {
86
+ return (
87
+ <>
88
+ <h3 className="m-0 text-base font-semibold">{props.label('update-card-title')}</h3>
89
+ <div className="mt-4 flex items-end gap-4">
90
+ <Version caption={props.label('update-card-current')} version={props.card.current} />
91
+ <span className="pb-1.5 text-muted-foreground" aria-hidden>→</span>
92
+ <Version caption={props.label('update-card-latest')} version={props.card.latest} strong />
93
+ {props.card.channel === undefined ? null : (
94
+ <span className="mb-0.5 rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
95
+ {props.label(channelKeys[props.card.channel])}
96
+ </span>
97
+ )}
98
+ </div>
99
+ <p className="mt-4 text-sm text-muted-foreground">{props.label('update-card-note')}</p>
100
+ <div className="mt-5 flex justify-end gap-2">
101
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onClose}>{props.label('cancel')}</button>
102
+ <button
103
+ type="button"
104
+ className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground"
105
+ onClick={() => props.onInstall(props.card.latest)}
106
+ >{props.label('update-install')}</button>
107
+ </div>
108
+ </>
109
+ )
110
+ }
111
+
112
+ function Installing(props: {
113
+ readonly card: Extract<UpdateCard, { kind: 'installing' }>
114
+ readonly label: (key: string) => string
115
+ }): ReactNode {
116
+ const [elapsed, setElapsed] = useState(() => Math.max(0, Date.now() - props.card.startedAt))
117
+ useEffect(() => {
118
+ const started = props.card.startedAt
119
+ const tick = setInterval(() => setElapsed(Math.max(0, Date.now() - started)), 1_000)
120
+ return () => { clearInterval(tick) }
121
+ }, [props.card.startedAt])
122
+ return (
123
+ <>
124
+ <h3 className="m-0 flex items-center gap-2 text-base font-semibold">
125
+ <Loader2 size={16} className="animate-spin" aria-hidden />
126
+ {props.label('update-installing-title').replace('{n}', props.card.version)}
127
+ </h3>
128
+ <div className="mt-5 h-0.5 w-full overflow-hidden rounded-full bg-muted">
129
+ <span className="block h-full w-1/3 animate-pulse rounded-full bg-foreground/40" />
130
+ </div>
131
+ <p className="mt-4 font-mono text-sm text-muted-foreground">{props.label('update-installing-elapsed').replace('{n}', clockText(elapsed))}</p>
132
+ <p className="mt-1 text-sm text-muted-foreground">{props.label('update-installing-note')}</p>
133
+ </>
134
+ )
135
+ }
136
+
137
+ function Failed(props: {
138
+ readonly card: Extract<UpdateCard, { kind: 'failed' }>
139
+ readonly label: (key: string) => string
140
+ readonly onClose: () => void
141
+ readonly onInstall: (version: string) => void
142
+ }): ReactNode {
143
+ return (
144
+ <>
145
+ <h3 className="m-0 text-base font-semibold">{props.label('update-failed-title')}</h3>
146
+ <p className="mt-3 text-sm">{reasonText(props.card.reason, props.label)}</p>
147
+ {props.card.back === undefined ? null : (
148
+ <p className="mt-1 text-sm text-muted-foreground">{props.label('update-failed-back').replace('{n}', props.card.back)}</p>
149
+ )}
150
+ {props.card.log === undefined ? null : (
151
+ <p className="mt-3 break-all font-mono text-[11px] text-muted-foreground">{props.label('update-log').replace('{n}', props.card.log)}</p>
152
+ )}
153
+ <div className="mt-5 flex justify-end gap-2">
154
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onClose}>{props.label('update-card-close')}</button>
155
+ {props.card.target === undefined ? null : (
156
+ <button
157
+ type="button"
158
+ className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground"
159
+ onClick={() => {
160
+ const target = props.card.target
161
+ if (target !== undefined) props.onInstall(target)
162
+ }}
163
+ >{props.label('update-card-retry')}</button>
164
+ )}
165
+ </div>
166
+ </>
167
+ )
168
+ }
169
+
170
+ function Done(props: {
171
+ readonly card: Extract<UpdateCard, { kind: 'done' }>
172
+ readonly label: (key: string) => string
173
+ readonly onClose: () => void
174
+ }): ReactNode {
175
+ return (
176
+ <>
177
+ <h3 className="m-0 text-base font-semibold">{props.label('update-card-done-title').replace('{n}', props.card.version)}</h3>
178
+ <p className="mt-3 text-sm text-muted-foreground">{props.label('update-card-done-note')}</p>
179
+ <div className="mt-5 flex justify-end">
180
+ <button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={props.onClose}>
181
+ {props.label('update-card-ok')}
182
+ </button>
183
+ </div>
184
+ </>
185
+ )
186
+ }
187
+
188
+ function Version(props: { readonly caption: string; readonly version: string; readonly strong?: boolean }): ReactNode {
189
+ return (
190
+ <span className="flex flex-col gap-1">
191
+ <span className="text-[10px] tracking-wide text-muted-foreground uppercase">{props.caption}</span>
192
+ <span className={props.strong === true ? 'font-mono text-lg font-semibold' : 'font-mono text-lg'}>{props.version}</span>
193
+ </span>
194
+ )
195
+ }
196
+
197
+ function reasonText(reason: UpdateReason, label: (key: string) => string): string {
198
+ if ('text' in reason) return reason.text
199
+ const text = label(reasonKeys[reason.code])
200
+ if (reason.code !== 'exit') return text
201
+ return reason.exitCode === undefined ? label('update-reason-exit-plain') : text.replace('{n}', String(reason.exitCode))
202
+ }
203
+
204
+ function clockText(milliseconds: number): string {
205
+ const total = Math.floor(milliseconds / 1_000)
206
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
207
+ }