@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.
- package/README.md +8 -0
- package/lib/types/chrome/hue.d.ts +3 -0
- package/lib/types/chrome/hue.d.ts.map +1 -0
- package/lib/types/gallery/card-style.d.ts +8 -0
- package/lib/types/gallery/card-style.d.ts.map +1 -0
- package/lib/types/gallery/client.d.ts +20 -0
- package/lib/types/gallery/client.d.ts.map +1 -0
- package/lib/types/gallery/desk.d.ts +16 -0
- package/lib/types/gallery/desk.d.ts.map +1 -0
- package/lib/types/gallery/list.d.ts +37 -0
- package/lib/types/gallery/list.d.ts.map +1 -0
- package/lib/types/gallery/sort.d.ts +24 -0
- package/lib/types/gallery/sort.d.ts.map +1 -0
- package/lib/types/gallery/state.d.ts +90 -0
- package/lib/types/gallery/state.d.ts.map +1 -0
- package/lib/types/gallery/tabs.d.ts +45 -0
- package/lib/types/gallery/tabs.d.ts.map +1 -0
- package/lib/types/gallery/view.d.ts +57 -0
- package/lib/types/gallery/view.d.ts.map +1 -0
- package/lib/types/history/client.d.ts +25 -0
- package/lib/types/history/client.d.ts.map +1 -0
- package/lib/types/history/state.d.ts +39 -0
- package/lib/types/history/state.d.ts.map +1 -0
- package/lib/types/history/view.d.ts +15 -0
- package/lib/types/history/view.d.ts.map +1 -0
- package/lib/types/index.d.ts +24 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/labels.d.ts +267 -0
- package/lib/types/labels.d.ts.map +1 -0
- package/lib/types/settings/author-mcp-view.d.ts +15 -0
- package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
- package/lib/types/settings/author-mcp.d.ts +4 -0
- package/lib/types/settings/author-mcp.d.ts.map +1 -0
- package/lib/types/settings/client.d.ts +198 -0
- package/lib/types/settings/client.d.ts.map +1 -0
- package/lib/types/settings/credentials-view.d.ts +15 -0
- package/lib/types/settings/credentials-view.d.ts.map +1 -0
- package/lib/types/settings/form.d.ts +27 -0
- package/lib/types/settings/form.d.ts.map +1 -0
- package/lib/types/settings/install-row.d.ts +10 -0
- package/lib/types/settings/install-row.d.ts.map +1 -0
- package/lib/types/settings/mcp-view.d.ts +14 -0
- package/lib/types/settings/mcp-view.d.ts.map +1 -0
- package/lib/types/settings/skill-dirs.d.ts +6 -0
- package/lib/types/settings/skill-dirs.d.ts.map +1 -0
- package/lib/types/settings/skill-install.d.ts +13 -0
- package/lib/types/settings/skill-install.d.ts.map +1 -0
- package/lib/types/settings/state.d.ts +94 -0
- package/lib/types/settings/state.d.ts.map +1 -0
- package/lib/types/settings/update-dialog.d.ts +56 -0
- package/lib/types/settings/update-dialog.d.ts.map +1 -0
- package/lib/types/settings/view.d.ts +26 -0
- package/lib/types/settings/view.d.ts.map +1 -0
- package/lib/types/storage/client.d.ts +15 -0
- package/lib/types/storage/client.d.ts.map +1 -0
- package/lib/types/storage/state.d.ts +42 -0
- package/lib/types/storage/state.d.ts.map +1 -0
- package/lib/types/storage/view.d.ts +16 -0
- package/lib/types/storage/view.d.ts.map +1 -0
- package/lib/types/surface/state.d.ts +42 -0
- package/lib/types/surface/state.d.ts.map +1 -0
- package/lib/types/surface/view.d.ts +34 -0
- package/lib/types/surface/view.d.ts.map +1 -0
- package/lib/types/theme/client.d.ts +45 -0
- package/lib/types/theme/client.d.ts.map +1 -0
- package/lib/types/theme/menu.d.ts +25 -0
- package/lib/types/theme/menu.d.ts.map +1 -0
- package/lib/types/theme/state.d.ts +46 -0
- package/lib/types/theme/state.d.ts.map +1 -0
- package/lib/types/theme/view.d.ts +16 -0
- package/lib/types/theme/view.d.ts.map +1 -0
- package/lib/types/ui/dialog.d.ts +12 -0
- package/lib/types/ui/dialog.d.ts.map +1 -0
- package/lib/types/ui/tooltip.d.ts +12 -0
- package/lib/types/ui/tooltip.d.ts.map +1 -0
- package/package.json +38 -0
- package/src/chrome/hue.ts +6 -0
- package/src/gallery/card-style.ts +27 -0
- package/src/gallery/client.ts +26 -0
- package/src/gallery/desk.tsx +54 -0
- package/src/gallery/list.ts +54 -0
- package/src/gallery/sort.ts +72 -0
- package/src/gallery/state.ts +229 -0
- package/src/gallery/tabs.ts +85 -0
- package/src/gallery/view.tsx +501 -0
- package/src/history/client.ts +27 -0
- package/src/history/state.ts +90 -0
- package/src/history/view.tsx +131 -0
- package/src/index.ts +61 -0
- package/src/labels.ts +536 -0
- package/src/settings/author-mcp-view.tsx +154 -0
- package/src/settings/author-mcp.ts +24 -0
- package/src/settings/client.ts +205 -0
- package/src/settings/credentials-view.tsx +195 -0
- package/src/settings/form.ts +27 -0
- package/src/settings/install-row.tsx +35 -0
- package/src/settings/mcp-view.tsx +793 -0
- package/src/settings/skill-dirs.ts +41 -0
- package/src/settings/skill-install.tsx +134 -0
- package/src/settings/state.ts +225 -0
- package/src/settings/update-dialog.tsx +207 -0
- package/src/settings/view.tsx +722 -0
- package/src/storage/client.ts +16 -0
- package/src/storage/state.ts +94 -0
- package/src/storage/view.tsx +190 -0
- package/src/surface/state.ts +72 -0
- package/src/surface/view.tsx +255 -0
- package/src/theme/client.ts +34 -0
- package/src/theme/menu.tsx +286 -0
- package/src/theme/state.ts +113 -0
- package/src/theme/view.tsx +73 -0
- package/src/ui/dialog.tsx +25 -0
- 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
|
+
}
|