anentrypoint-design 0.0.369 → 0.0.371
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/chat.css +128 -0
- package/dist/247420.css +454 -1
- package/dist/247420.js +27 -27
- package/package.json +1 -1
- package/src/components/chat-message-parts.js +42 -4
- package/src/components/chat-minimap.js +309 -0
- package/src/components/chat.js +2 -1
- package/src/components/editor-primitives.js +41 -14
- package/src/components/files-modals.js +44 -4
- package/src/components/git-status.js +22 -6
- package/src/components/interaction-primitives.js +27 -0
- package/src/components/models-config.js +229 -0
- package/src/components/plugins-config.js +154 -0
- package/src/components/sessions.js +138 -38
- package/src/components/skills-config.js +188 -0
- package/src/components/voice.js +41 -0
- package/src/components.js +12 -2
- package/src/css/app-shell/chat-polish.css +25 -0
- package/src/css/app-shell/files.css +18 -0
- package/src/css/app-shell/models-config.css +93 -0
- package/src/css/app-shell/plugins-config.css +139 -0
- package/src/css/app-shell/skills-config.css +27 -0
- package/src/file-mention.js +142 -0
- package/src/index.js +6 -0
- package/src/math.js +158 -0
- package/src/mermaid.js +105 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// ModelsConfig — provider/model availability panel, ported from pi-web's
|
|
2
|
+
// ModelsConfig.tsx UX (left tree of providers -> models, right detail pane,
|
|
3
|
+
// status chips, refresh action) but rebound to freddie's REAL backend shape:
|
|
4
|
+
// GET /api/models/availability, not pi-web's editable ~/.pi/agent/models.json.
|
|
5
|
+
//
|
|
6
|
+
// freddie's schema (see AGENTS.md "Model availability matrix"):
|
|
7
|
+
// {
|
|
8
|
+
// timestamp, config, daemons,
|
|
9
|
+
// providers: [{ id, key_present, discovery_error,
|
|
10
|
+
// models: [{ id, discovered_via, modes: {<mode>: {ok,
|
|
11
|
+
// latency_ms, excerpt?, error?, skipped?, reason?}},
|
|
12
|
+
// usable_in_any_mode }] }],
|
|
13
|
+
// sampler: [{ provider, ok, failCount, nextCheckIn }],
|
|
14
|
+
// summary: { total_providers, total_models, usable_in_any_mode, per_mode_counts },
|
|
15
|
+
// }
|
|
16
|
+
// This is a read-only witness of provider reachability, not an editable
|
|
17
|
+
// provider-config form — so unlike pi-web's modal editor (add/rename/delete
|
|
18
|
+
// provider, edit models.json, save/cancel), this component is a plain
|
|
19
|
+
// non-modal panel: pick a provider in the left tree, see its models + mode
|
|
20
|
+
// grid + sampler backoff state on the right. No local edit state at all.
|
|
21
|
+
//
|
|
22
|
+
// Usage (host owns fetch/poll, this component is pure render + callbacks):
|
|
23
|
+
// ModelsConfig({ data, loading, error, selectedProviderId, onSelectProvider,
|
|
24
|
+
// selectedModel, onSelectModel, onRefresh, rebuilding, onRebuild })
|
|
25
|
+
//
|
|
26
|
+
// Props:
|
|
27
|
+
// data : the raw GET /api/models/availability JSON, or null/undefined
|
|
28
|
+
// loading : true while the initial fetch is in flight
|
|
29
|
+
// error : { error, hint } from a 404 (file absent) or other fetch failure, or a string
|
|
30
|
+
// selectedProviderId : id of the provider shown in the detail pane (defaults to first)
|
|
31
|
+
// onSelectProvider : callback(providerId) fired when a provider row is clicked
|
|
32
|
+
// selectedModel : { providerId, modelId } OPTIONAL - lets a host (e.g. chat
|
|
33
|
+
// panel) drive model selection off the same list
|
|
34
|
+
// onSelectModel : OPTIONAL callback({ providerId, modelId }) fired on model row click
|
|
35
|
+
// onRefresh : OPTIONAL callback() - re-fetch GET /api/models/availability
|
|
36
|
+
// onRebuild : OPTIONAL callback() - POST /api/models/availability/rebuild
|
|
37
|
+
// rebuilding : true while a rebuild job is known in flight (202 seen, not yet settled)
|
|
38
|
+
// rebuildError : OPTIONAL string - surfaced on a 409 (rebuild already in flight) or other failure
|
|
39
|
+
//
|
|
40
|
+
// No decorative glyphs — the kit's Icon SVGs + status words only.
|
|
41
|
+
|
|
42
|
+
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
43
|
+
import { Panel, Row } from './content.js';
|
|
44
|
+
import { Btn, Icon } from './shell.js';
|
|
45
|
+
|
|
46
|
+
const h = webjsx.createElement;
|
|
47
|
+
|
|
48
|
+
const MODES = [
|
|
49
|
+
'direct_api', 'acptoapi_passthrough', 'freddie_v1',
|
|
50
|
+
'kilo_acp', 'opencode_acp', 'claude_cli', 'freddie_agent_loop',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const MODE_LABEL = {
|
|
54
|
+
direct_api: 'direct',
|
|
55
|
+
acptoapi_passthrough: 'acptoapi',
|
|
56
|
+
freddie_v1: 'freddie v1',
|
|
57
|
+
kilo_acp: 'kilo acp',
|
|
58
|
+
opencode_acp: 'opencode acp',
|
|
59
|
+
claude_cli: 'claude cli',
|
|
60
|
+
freddie_agent_loop: 'agent loop',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function fmtAgo(iso) {
|
|
64
|
+
if (!iso) return null;
|
|
65
|
+
const t = Date.parse(iso);
|
|
66
|
+
if (Number.isNaN(t)) return String(iso);
|
|
67
|
+
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
|
68
|
+
if (s < 60) return s + 's ago';
|
|
69
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
70
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
71
|
+
return Math.floor(s / 86400) + 'd ago';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// One cell of the mode-availability grid — a compact chip whose tone/label
|
|
75
|
+
// covers all four states a matrix cell can carry: ok, fail, skipped, unknown
|
|
76
|
+
// (the model/mode combination is simply absent from `modes`).
|
|
77
|
+
function ModeChip({ mode, cell }) {
|
|
78
|
+
const label = MODE_LABEL[mode] || mode;
|
|
79
|
+
if (!cell) {
|
|
80
|
+
return h('span', { class: 'ds-mc-chip tone-unknown', title: label + ': not probed' }, label);
|
|
81
|
+
}
|
|
82
|
+
if (cell.skipped) {
|
|
83
|
+
const reason = cell.reason || 'skipped';
|
|
84
|
+
return h('span', { class: 'ds-mc-chip tone-skip', title: label + ': ' + reason }, label);
|
|
85
|
+
}
|
|
86
|
+
if (cell.ok) {
|
|
87
|
+
const lat = cell.latency_ms != null ? ' ' + cell.latency_ms + 'ms' : '';
|
|
88
|
+
return h('span', { class: 'ds-mc-chip tone-ok', title: label + ': ok' + lat }, label);
|
|
89
|
+
}
|
|
90
|
+
const err = cell.error || 'failed';
|
|
91
|
+
return h('span', { class: 'ds-mc-chip tone-fail', title: label + ': ' + err }, label);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function SamplerBadge({ sampler }) {
|
|
95
|
+
if (!sampler) return null;
|
|
96
|
+
const tone = sampler.ok ? 'ok' : 'fail';
|
|
97
|
+
const text = sampler.ok
|
|
98
|
+
? 'sampler ok'
|
|
99
|
+
: 'sampler backoff' + (sampler.failCount != null ? ' x' + sampler.failCount : '') +
|
|
100
|
+
(sampler.nextCheckIn != null ? ' (retry in ' + sampler.nextCheckIn + ')' : '');
|
|
101
|
+
return h('span', { class: 'ds-mc-sampler tone-' + tone }, text);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Left tree: provider rows (key-present / missing-key / discovery-error state)
|
|
105
|
+
// each expanding into its model rows when selected.
|
|
106
|
+
function ProviderTree({ providers, samplerById, selectedProviderId, onSelectProvider }) {
|
|
107
|
+
return h('div', { class: 'ds-mc-tree', role: 'listbox', 'aria-label': 'providers' },
|
|
108
|
+
...providers.map((p) => {
|
|
109
|
+
const isSelected = p.id === selectedProviderId;
|
|
110
|
+
const usableCount = (p.models || []).filter((m) => m.usable_in_any_mode).length;
|
|
111
|
+
const rail = p.discovery_error ? 'flame' : (!p.key_present ? null : (usableCount > 0 ? 'green' : null));
|
|
112
|
+
return h('div', { key: p.id, class: 'ds-mc-tree-group' },
|
|
113
|
+
Row({
|
|
114
|
+
title: p.id,
|
|
115
|
+
sub: p.discovery_error
|
|
116
|
+
? p.discovery_error
|
|
117
|
+
: (usableCount + '/' + (p.models || []).length + ' models usable'),
|
|
118
|
+
meta: h('span', { class: 'ds-mc-key-chip' + (p.key_present ? ' tone-ok' : ' tone-fail') },
|
|
119
|
+
p.key_present ? 'key set' : 'no key'),
|
|
120
|
+
rail,
|
|
121
|
+
selected: isSelected,
|
|
122
|
+
onClick: () => onSelectProvider && onSelectProvider(p.id),
|
|
123
|
+
}),
|
|
124
|
+
isSelected ? SamplerBadge({ sampler: samplerById.get(p.id) }) : null,
|
|
125
|
+
);
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Right detail: the selected provider's models, each with its mode-grid and
|
|
130
|
+
// (optionally) a model-select affordance for a host driving chat model choice.
|
|
131
|
+
function ProviderDetail({ provider, samplerById, selectedModel, onSelectModel }) {
|
|
132
|
+
if (!provider) {
|
|
133
|
+
return h('div', { class: 'ds-mc-detail-empty' }, 'select a provider');
|
|
134
|
+
}
|
|
135
|
+
const models = provider.models || [];
|
|
136
|
+
return h('div', { class: 'ds-mc-detail' },
|
|
137
|
+
h('div', { class: 'ds-mc-detail-head' },
|
|
138
|
+
h('span', { class: 'ds-mc-detail-title' }, provider.id),
|
|
139
|
+
SamplerBadge({ sampler: samplerById.get(provider.id) })),
|
|
140
|
+
provider.discovery_error
|
|
141
|
+
? h('div', { class: 'ds-mc-discovery-error' },
|
|
142
|
+
h('span', { 'aria-hidden': 'true' }, Icon('warn', { size: 14 })),
|
|
143
|
+
h('span', {}, provider.discovery_error))
|
|
144
|
+
: null,
|
|
145
|
+
!models.length
|
|
146
|
+
? h('div', { class: 'ds-mc-detail-empty' }, 'no models discovered for this provider')
|
|
147
|
+
: h('div', { class: 'ds-mc-model-list' }, ...models.map((m) => {
|
|
148
|
+
const isSelModel = selectedModel && selectedModel.providerId === provider.id && selectedModel.modelId === m.id;
|
|
149
|
+
return h('div', { key: m.id, class: 'ds-mc-model-row' + (isSelModel ? ' active' : '') },
|
|
150
|
+
h('div', { class: 'ds-mc-model-head' },
|
|
151
|
+
h('span', { class: 'ds-mc-model-id' }, m.id),
|
|
152
|
+
h('span', { class: 'ds-mc-model-via' }, m.discovered_via ? 'via ' + m.discovered_via : null),
|
|
153
|
+
h('span', {
|
|
154
|
+
class: 'ds-mc-usable-chip' + (m.usable_in_any_mode ? ' tone-ok' : ' tone-fail'),
|
|
155
|
+
}, m.usable_in_any_mode ? 'usable' : 'unusable'),
|
|
156
|
+
onSelectModel ? Btn({
|
|
157
|
+
size: 'sm',
|
|
158
|
+
variant: isSelModel ? 'default' : 'ghost',
|
|
159
|
+
children: isSelModel ? 'selected' : 'select',
|
|
160
|
+
onClick: () => onSelectModel({ providerId: provider.id, modelId: m.id }),
|
|
161
|
+
}) : null),
|
|
162
|
+
h('div', { class: 'ds-mc-mode-grid' },
|
|
163
|
+
...MODES.map((mode) => ModeChip({ mode, cell: m.modes && m.modes[mode] }))));
|
|
164
|
+
})));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function SummaryBar({ summary, timestamp, onRefresh, onRebuild, rebuilding, rebuildError }) {
|
|
168
|
+
return h('div', { class: 'ds-mc-summary' },
|
|
169
|
+
h('div', { class: 'ds-mc-summary-facts' },
|
|
170
|
+
summary ? h('span', {}, (summary.usable_in_any_mode ?? 0) + '/' + (summary.total_models ?? 0) + ' models usable') : null,
|
|
171
|
+
summary ? h('span', {}, summary.total_providers + ' providers') : null,
|
|
172
|
+
timestamp ? h('span', { class: 'ds-mc-summary-ts' }, 'checked ' + fmtAgo(timestamp)) : null),
|
|
173
|
+
h('div', { class: 'ds-mc-summary-actions' },
|
|
174
|
+
rebuildError ? h('span', { class: 'ds-mc-rebuild-error' }, rebuildError) : null,
|
|
175
|
+
onRefresh ? Btn({ size: 'sm', variant: 'ghost', onClick: onRefresh, children: 'refresh' }) : null,
|
|
176
|
+
onRebuild ? Btn({
|
|
177
|
+
size: 'sm',
|
|
178
|
+
disabled: !!rebuilding,
|
|
179
|
+
onClick: onRebuild,
|
|
180
|
+
children: rebuilding ? 'rebuilding…' : 'rebuild',
|
|
181
|
+
}) : null));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function ModelsConfig({
|
|
185
|
+
data, loading, error,
|
|
186
|
+
selectedProviderId, onSelectProvider,
|
|
187
|
+
selectedModel, onSelectModel,
|
|
188
|
+
onRefresh, onRebuild, rebuilding, rebuildError,
|
|
189
|
+
} = {}) {
|
|
190
|
+
if (loading && !data) {
|
|
191
|
+
return h('div', { class: 'ds-mc' }, h('div', { class: 'ds-mc-loading', role: 'status' }, 'loading model availability…'));
|
|
192
|
+
}
|
|
193
|
+
// 404 (file absent) per freddie AGENTS.md: {error, hint}. A generic string
|
|
194
|
+
// error is also accepted so a host can pass a plain fetch-failure message.
|
|
195
|
+
if (error) {
|
|
196
|
+
const msg = typeof error === 'string' ? error : (error.error || 'failed to load model availability');
|
|
197
|
+
const hint = typeof error === 'object' ? error.hint : null;
|
|
198
|
+
return h('div', { class: 'ds-mc' },
|
|
199
|
+
h('div', { class: 'ds-mc-empty', role: 'status' },
|
|
200
|
+
h('span', { 'aria-hidden': 'true' }, Icon('circle-dot', { size: 22 })),
|
|
201
|
+
h('span', {}, msg),
|
|
202
|
+
hint ? h('span', { class: 'ds-mc-empty-hint' }, hint) : null,
|
|
203
|
+
onRebuild ? Btn({ size: 'sm', onClick: onRebuild, disabled: !!rebuilding, children: rebuilding ? 'rebuilding…' : 'build availability matrix' }) : null));
|
|
204
|
+
}
|
|
205
|
+
const providers = (data && data.providers) || [];
|
|
206
|
+
if (!providers.length) {
|
|
207
|
+
return h('div', { class: 'ds-mc' },
|
|
208
|
+
h('div', { class: 'ds-mc-empty', role: 'status' },
|
|
209
|
+
h('span', { 'aria-hidden': 'true' }, Icon('circle-dot', { size: 22 })),
|
|
210
|
+
h('span', {}, 'no providers discovered'),
|
|
211
|
+
onRefresh ? Btn({ size: 'sm', variant: 'ghost', onClick: onRefresh, children: 'refresh' }) : null));
|
|
212
|
+
}
|
|
213
|
+
const samplerById = new Map((data.sampler || []).map((s) => [s.provider, s]));
|
|
214
|
+
const activeId = selectedProviderId || providers[0].id;
|
|
215
|
+
const activeProvider = providers.find((p) => p.id === activeId) || providers[0];
|
|
216
|
+
return h('div', { class: 'ds-mc' },
|
|
217
|
+
SummaryBar({ summary: data.summary, timestamp: data.timestamp, onRefresh, onRebuild, rebuilding, rebuildError }),
|
|
218
|
+
h('div', { class: 'ds-mc-body' },
|
|
219
|
+
Panel({
|
|
220
|
+
title: 'providers',
|
|
221
|
+
class: 'ds-mc-tree-panel',
|
|
222
|
+
children: [ProviderTree({ providers, samplerById, selectedProviderId: activeId, onSelectProvider })],
|
|
223
|
+
}),
|
|
224
|
+
Panel({
|
|
225
|
+
title: 'models',
|
|
226
|
+
class: 'ds-mc-detail-panel',
|
|
227
|
+
children: [ProviderDetail({ provider: activeProvider, samplerById, selectedModel, onSelectModel })],
|
|
228
|
+
})));
|
|
229
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// PluginsConfig — plugin/extension list + detail panel, ported from pi-web's
|
|
2
|
+
// PluginsConfig.tsx UX (modal, sidebar list grouped by scope, detail pane with
|
|
3
|
+
// enable/disable toggle, add-plugin flow, diagnostics footer) but rebuilt over
|
|
4
|
+
// freddie's real plugin contract, not pi-web's npm-package model:
|
|
5
|
+
//
|
|
6
|
+
// { name, version?, surfaces: 'pi'|'gui'|'both', requires?: [...names], source? }
|
|
7
|
+
//
|
|
8
|
+
// (see freddie's AGENTS.md "Plugin architecture" — `src/host/contract.js`).
|
|
9
|
+
// There is no install/remove/update here: freddie plugins are local-filesystem
|
|
10
|
+
// discovery only (`plugins/<name>/plugin.js`, `~/.freddie/plugins/`), so the
|
|
11
|
+
// only host-facing actions are enable/disable and reload. `requires` renders
|
|
12
|
+
// as a dependency list (freddie's cycle-checked `requires` array) in place of
|
|
13
|
+
// pi-web's package version/resource breakdown.
|
|
14
|
+
//
|
|
15
|
+
// Usage (consumer wires its own state/fetch, this is presentation-only):
|
|
16
|
+
// PluginsConfig({ plugins, selected, onSelect, onToggle, onReload, onClose })
|
|
17
|
+
//
|
|
18
|
+
// Props:
|
|
19
|
+
// plugins : [{ name, version?, surfaces, requires?, source?, enabled, status? }]
|
|
20
|
+
// status is an optional free-text chip ('loaded'|'error'|...);
|
|
21
|
+
// enabled drives the toggle and the sidebar status dot.
|
|
22
|
+
// selected : name of the currently-selected plugin, or null
|
|
23
|
+
// loading : bool — sidebar shows a loading row instead of the list
|
|
24
|
+
// error : string|null — sidebar shows this instead of the list
|
|
25
|
+
// busyName : name of the plugin currently mid-toggle/reload, or null
|
|
26
|
+
// onSelect : (name) => void
|
|
27
|
+
// onToggle : (plugin) => void — fired with the full plugin row to flip enabled
|
|
28
|
+
// onReload : () => void — optional, re-run host discovery
|
|
29
|
+
// onClose : () => void
|
|
30
|
+
//
|
|
31
|
+
// No decorative glyphs beyond the kit's Icon SVGs — status communicated by a
|
|
32
|
+
// tone dot + text label, never color alone.
|
|
33
|
+
|
|
34
|
+
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
35
|
+
import { Icon } from './shell.js';
|
|
36
|
+
const h = webjsx.createElement;
|
|
37
|
+
|
|
38
|
+
function statusTone(plugin) {
|
|
39
|
+
if (!plugin.enabled) return 'neutral';
|
|
40
|
+
if (plugin.status === 'error') return 'delete';
|
|
41
|
+
return 'add';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function statusLabel(plugin) {
|
|
45
|
+
if (!plugin.enabled) return 'disabled';
|
|
46
|
+
if (plugin.status) return plugin.status;
|
|
47
|
+
return 'enabled';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function surfacesText(surfaces) {
|
|
51
|
+
if (!surfaces) return '—';
|
|
52
|
+
return surfaces;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function PluginSidebarRow({ plugin, active, busy, onSelect }) {
|
|
56
|
+
return h('button', {
|
|
57
|
+
type: 'button',
|
|
58
|
+
class: 'ds-plugins-row' + (active ? ' active' : ''),
|
|
59
|
+
onclick: () => onSelect(plugin.name),
|
|
60
|
+
'aria-pressed': active ? 'true' : 'false',
|
|
61
|
+
'aria-label': plugin.name + ': ' + statusLabel(plugin),
|
|
62
|
+
},
|
|
63
|
+
h('span', { class: 'ds-plugins-dot tone-' + statusTone(plugin), 'aria-hidden': 'true' }),
|
|
64
|
+
h('span', { class: 'ds-plugins-row-body' },
|
|
65
|
+
h('span', { class: 'ds-plugins-row-name' }, plugin.name),
|
|
66
|
+
h('span', { class: 'ds-plugins-row-meta' },
|
|
67
|
+
surfacesText(plugin.surfaces),
|
|
68
|
+
plugin.requires && plugin.requires.length ? ' · ' + plugin.requires.length + ' req' : '')),
|
|
69
|
+
busy ? h('span', { class: 'ds-plugins-row-busy' }, '…') : null);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function PluginDetail({ plugin, busy, onToggle, onReload }) {
|
|
73
|
+
if (!plugin) {
|
|
74
|
+
return h('div', { class: 'ds-plugins-empty', role: 'status' },
|
|
75
|
+
h('span', { 'aria-hidden': 'true' }, Icon('circle-dot', { size: 22 })),
|
|
76
|
+
h('span', {}, 'Select a plugin'));
|
|
77
|
+
}
|
|
78
|
+
const requires = Array.isArray(plugin.requires) ? plugin.requires : [];
|
|
79
|
+
return h('div', { class: 'ds-plugins-detail' },
|
|
80
|
+
h('div', { class: 'ds-plugins-detail-head' },
|
|
81
|
+
h('div', { class: 'ds-plugins-detail-title' },
|
|
82
|
+
h('span', { class: 'ds-plugins-dot tone-' + statusTone(plugin), 'aria-hidden': 'true' }),
|
|
83
|
+
h('span', { class: 'name' }, plugin.name),
|
|
84
|
+
plugin.version ? h('span', { class: 'ds-plugins-version' }, 'v' + plugin.version) : null),
|
|
85
|
+
h('button', {
|
|
86
|
+
type: 'button',
|
|
87
|
+
class: 'ds-plugins-toggle' + (plugin.enabled ? ' on' : ''),
|
|
88
|
+
disabled: busy ? true : null,
|
|
89
|
+
onclick: () => onToggle && onToggle(plugin),
|
|
90
|
+
'aria-pressed': plugin.enabled ? 'true' : 'false',
|
|
91
|
+
'aria-label': plugin.enabled ? 'Disable plugin' : 'Enable plugin',
|
|
92
|
+
}, h('span', { class: 'ds-plugins-toggle-knob' }))),
|
|
93
|
+
h('div', { class: 'ds-plugins-fact-grid' },
|
|
94
|
+
h('div', { class: 'ds-plugins-fact-label' }, 'status'),
|
|
95
|
+
h('div', { class: 'ds-plugins-fact-value tone-text-' + statusTone(plugin) }, statusLabel(plugin)),
|
|
96
|
+
h('div', { class: 'ds-plugins-fact-label' }, 'surfaces'),
|
|
97
|
+
h('div', { class: 'ds-plugins-fact-value' }, surfacesText(plugin.surfaces)),
|
|
98
|
+
plugin.source ? h('div', { class: 'ds-plugins-fact-label' }, 'source') : null,
|
|
99
|
+
plugin.source ? h('div', { class: 'ds-plugins-fact-value ds-plugins-mono' }, plugin.source) : null),
|
|
100
|
+
h('div', { class: 'ds-plugins-requires' },
|
|
101
|
+
h('div', { class: 'ds-plugins-group-label' }, 'requires'),
|
|
102
|
+
requires.length
|
|
103
|
+
? h('div', { class: 'ds-plugins-requires-list' },
|
|
104
|
+
...requires.map((r) => h('span', { key: r, class: 'ds-plugins-chip' }, r)))
|
|
105
|
+
: h('div', { class: 'ds-plugins-requires-empty' }, 'no dependencies')),
|
|
106
|
+
onReload
|
|
107
|
+
? h('div', { class: 'ds-plugins-detail-actions' },
|
|
108
|
+
h('button', {
|
|
109
|
+
type: 'button',
|
|
110
|
+
class: 'ds-plugins-btn',
|
|
111
|
+
disabled: busy ? true : null,
|
|
112
|
+
onclick: onReload,
|
|
113
|
+
}, busy ? 'Reloading…' : 'Reload plugins'))
|
|
114
|
+
: null);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function PluginsConfig({
|
|
118
|
+
plugins = [],
|
|
119
|
+
selected = null,
|
|
120
|
+
loading = false,
|
|
121
|
+
error = null,
|
|
122
|
+
busyName = null,
|
|
123
|
+
onSelect,
|
|
124
|
+
onToggle,
|
|
125
|
+
onReload,
|
|
126
|
+
onClose,
|
|
127
|
+
} = {}) {
|
|
128
|
+
const selectedPlugin = plugins.find((p) => p.name === selected) || null;
|
|
129
|
+
return h('div', { class: 'ds-plugins-overlay', onclick: (e) => { if (e.target === e.currentTarget && onClose) onClose(); } },
|
|
130
|
+
h('div', { class: 'ds-plugins-modal', role: 'dialog', 'aria-label': 'Plugins' },
|
|
131
|
+
h('div', { class: 'ds-plugins-header' },
|
|
132
|
+
h('span', { class: 'ds-plugins-title' }, 'Plugins'),
|
|
133
|
+
onClose ? h('button', { type: 'button', class: 'ds-plugins-close', onclick: onClose, 'aria-label': 'Close' }, '×') : null),
|
|
134
|
+
h('div', { class: 'ds-plugins-body' },
|
|
135
|
+
h('div', { class: 'ds-plugins-sidebar' },
|
|
136
|
+
loading
|
|
137
|
+
? h('div', { class: 'ds-plugins-sidebar-status' }, 'Loading…')
|
|
138
|
+
: error
|
|
139
|
+
? h('div', { class: 'ds-plugins-sidebar-status ds-plugins-status-error' }, error)
|
|
140
|
+
: plugins.length === 0
|
|
141
|
+
? h('div', { class: 'ds-plugins-sidebar-status' }, 'No plugins registered')
|
|
142
|
+
: h('div', { class: 'ds-plugins-list', role: 'listbox', 'aria-label': 'plugin list' },
|
|
143
|
+
...plugins.map((p) => PluginSidebarRow({
|
|
144
|
+
key: p.name,
|
|
145
|
+
plugin: p,
|
|
146
|
+
active: selected === p.name,
|
|
147
|
+
busy: busyName === p.name,
|
|
148
|
+
onSelect,
|
|
149
|
+
})))),
|
|
150
|
+
h('div', { class: 'ds-plugins-main' },
|
|
151
|
+
PluginDetail({ plugin: selectedPlugin, busy: busyName === (selectedPlugin && selectedPlugin.name), onToggle, onReload }))),
|
|
152
|
+
h('div', { class: 'ds-plugins-footer' },
|
|
153
|
+
h('span', { class: 'ds-plugins-footer-count' }, plugins.length + ' plugin' + (plugins.length === 1 ? '' : 's')))));
|
|
154
|
+
}
|
|
@@ -44,7 +44,7 @@ export function fmtDuration(ms) {
|
|
|
44
44
|
* conversation.
|
|
45
45
|
*
|
|
46
46
|
* @param {Object} [props]
|
|
47
|
-
* @param {Array<{sid:*, title?:string, project?:string, agent?:string, time?:string, running?:boolean, unread?:boolean, rail?:string}>} [props.sessions=[]]
|
|
47
|
+
* @param {Array<{sid:*, title?:string, project?:string, agent?:string, time?:string, running?:boolean, unread?:boolean, rail?:string, parentSid?:*}>} [props.sessions=[]]
|
|
48
48
|
* @param {*} [props.selected] - the active sid.
|
|
49
49
|
* @param {Array<{label:string, sids:Array<*>}>} [props.groups] - OPTIONAL buckets for the rows; else one flat list.
|
|
50
50
|
* @param {{value:string, onInput:Function, placeholder?:string}} [props.search] - inline filter (optional).
|
|
@@ -53,6 +53,26 @@ export function fmtDuration(ms) {
|
|
|
53
53
|
* @param {string} [props.emptyText='No conversations yet']
|
|
54
54
|
* @param {boolean} [props.loading=false]
|
|
55
55
|
* @param {*} [props.error=null]
|
|
56
|
+
* @param {boolean} [props.tree=false] - OPTIONAL: nest rows whose `parentSid` matches
|
|
57
|
+
* another row's `sid` under that row (fork/branch tree), with an indent guide,
|
|
58
|
+
* a branch glyph, and a per-node collapse toggle. Ignored when `groups` is set
|
|
59
|
+
* (grouping and tree-nesting are mutually exclusive row layouts). `expanded`/
|
|
60
|
+
* `onToggleExpand` are host-driven (kit stays stateless): a `sid` NOT present
|
|
61
|
+
* in the `expanded` Set renders collapsed once it has children.
|
|
62
|
+
* @param {Set<*>|Array<*>} [props.expanded] - sids whose children are shown, when `tree`.
|
|
63
|
+
* @param {Function} [props.onToggleExpand] - onToggleExpand(sid), when `tree`.
|
|
64
|
+
* @param {Function} [props.onRename] - onRename(session, newTitle). Presence enables the
|
|
65
|
+
* hover-revealed rename action (row becomes an inline text input while active).
|
|
66
|
+
* @param {*} [props.renaming] - sid of the row currently in rename-edit mode (host-driven).
|
|
67
|
+
* @param {Function} [props.onStartRename] - onStartRename(session) - fired by the rename
|
|
68
|
+
* button click, before `onRename` commits; host flips `renaming` to this sid.
|
|
69
|
+
* @param {Function} [props.onCancelRename] - onCancelRename() - Escape / blur-without-change.
|
|
70
|
+
* @param {Function} [props.onDelete] - onDelete(session). Presence enables the hover-revealed
|
|
71
|
+
* delete action; clicking it arms an inline two-button confirm row (same height, no modal),
|
|
72
|
+
* mirroring SessionDashboard's arm-then-confirm stop control.
|
|
73
|
+
* @param {*} [props.confirmingDelete] - sid currently showing the armed delete-confirm state.
|
|
74
|
+
* @param {Function} [props.onArmDelete] - onArmDelete(session) - first delete click.
|
|
75
|
+
* @param {Function} [props.onCancelDelete] - onCancelDelete() - confirm-row Cancel click.
|
|
56
76
|
* @returns {*} webjsx vnode
|
|
57
77
|
*/
|
|
58
78
|
export function ConversationList({ sessions = [], selected, groups, search, caption,
|
|
@@ -68,41 +88,114 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
68
88
|
// aria-live region, so a real "N results" string (computed by the
|
|
69
89
|
// host from its filtered session list) reaches AT users instead of
|
|
70
90
|
// the region sitting permanently empty.
|
|
71
|
-
resultCount
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
resultCount,
|
|
92
|
+
// Fork/branch tree nesting (parentSid-driven), inline rename,
|
|
93
|
+
// inline delete — all host-driven, kit stays stateless.
|
|
94
|
+
tree = false, expanded, onToggleExpand,
|
|
95
|
+
onRename, renaming, onStartRename, onCancelRename,
|
|
96
|
+
onDelete, confirmingDelete, onArmDelete, onCancelDelete } = {}) {
|
|
97
|
+
const expSet = expanded instanceof Set ? expanded : new Set(expanded || []);
|
|
98
|
+
// childrenBySid: only consulted when `tree` is on - a flat caller pays nothing.
|
|
99
|
+
const childrenBySid = new Map();
|
|
100
|
+
if (tree) {
|
|
101
|
+
for (const s of sessions) {
|
|
102
|
+
if (s.parentSid == null) continue;
|
|
103
|
+
if (!childrenBySid.has(s.parentSid)) childrenBySid.set(s.parentSid, []);
|
|
104
|
+
childrenBySid.get(s.parentSid).push(s);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const isRenaming = (s) => renaming != null && s.sid === renaming;
|
|
108
|
+
const isConfirmingDelete = (s) => confirmingDelete != null && s.sid === confirmingDelete;
|
|
109
|
+
|
|
110
|
+
const rowFor = (s, i, depth = 0) => {
|
|
111
|
+
const hasKids = tree && childrenBySid.has(s.sid) && childrenBySid.get(s.sid).length > 0;
|
|
112
|
+
const kidsOpen = hasKids && expSet.has(s.sid);
|
|
113
|
+
const renamingThis = onRename && isRenaming(s);
|
|
114
|
+
const confirmingThis = onDelete && isConfirmingDelete(s);
|
|
115
|
+
let content;
|
|
116
|
+
if (confirmingThis) {
|
|
117
|
+
// Inline confirm: same row height, no modal - mirrors SessionDashboard's
|
|
118
|
+
// arm-then-confirm bulk-stop control so delete has one consistent shape
|
|
119
|
+
// across the kit.
|
|
120
|
+
content = [
|
|
121
|
+
h('span', { key: 'cd-msg', class: 'ds-session-confirm-msg' }, 'Delete "' + (s.title || s.project || s.sid || '') + '"?'),
|
|
122
|
+
h('span', { key: 'cd-acts', class: 'ds-session-confirm-actions' }, [
|
|
123
|
+
h('button', { key: 'cd-yes', type: 'button', class: 'ds-session-confirm-delete',
|
|
124
|
+
onclick: (e) => { e.stopPropagation(); onDelete(s); } }, 'delete'),
|
|
125
|
+
h('button', { key: 'cd-no', type: 'button', class: 'ds-session-confirm-cancel',
|
|
126
|
+
onclick: (e) => { e.stopPropagation(); onCancelDelete && onCancelDelete(); } }, 'cancel'),
|
|
127
|
+
]),
|
|
128
|
+
];
|
|
129
|
+
} else if (renamingThis) {
|
|
130
|
+
content = [
|
|
131
|
+
h('input', {
|
|
132
|
+
key: 'rn-input', class: 'ds-session-rename-input', type: 'text',
|
|
133
|
+
value: s.title || '', autofocus: true,
|
|
134
|
+
onclick: (e) => e.stopPropagation(),
|
|
135
|
+
onkeydown: (e) => {
|
|
136
|
+
if (e.key === 'Enter') { e.preventDefault(); onRename(s, e.target.value); }
|
|
137
|
+
if (e.key === 'Escape') { e.preventDefault(); onCancelRename && onCancelRename(); }
|
|
138
|
+
},
|
|
139
|
+
onblur: (e) => onRename(s, e.target.value),
|
|
140
|
+
}),
|
|
141
|
+
];
|
|
142
|
+
} else {
|
|
143
|
+
content = [
|
|
144
|
+
hasKids ? h('button', {
|
|
145
|
+
key: 'tog', type: 'button', class: 'ds-session-tree-toggle' + (kidsOpen ? ' open' : ''),
|
|
146
|
+
'aria-label': (kidsOpen ? 'collapse' : 'expand') + ' forks', 'aria-expanded': kidsOpen ? 'true' : 'false',
|
|
147
|
+
onclick: (e) => { e.stopPropagation(); onToggleExpand && onToggleExpand(s.sid); },
|
|
148
|
+
}, Icon('chevron-right', { size: 10 })) : null,
|
|
149
|
+
depth > 0 ? h('span', { key: 'fork', class: 'ds-session-fork-icon', 'aria-hidden': 'true' }, Icon('corner-up-left', { size: 10 })) : null,
|
|
150
|
+
h('span', { key: 'main', class: 'ds-session-main' }, [
|
|
151
|
+
// Two-sided truncation: the CSS ellipsis is paired with a title= carrying
|
|
152
|
+
// the full string, so a long title/project is recoverable on hover.
|
|
153
|
+
h('span', { class: 'ds-session-title', title: s.title || s.project || s.sid || null }, s.title || s.project || s.sid || ''),
|
|
154
|
+
(s.project || s.time) ? h('span', { class: 'ds-session-sub', title: s.project || null },
|
|
155
|
+
[s.project, s.time].filter(Boolean).join(' · ')) : null,
|
|
156
|
+
].filter(Boolean)),
|
|
157
|
+
h('span', { key: 'meta', class: 'ds-session-meta' }, [
|
|
158
|
+
s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
|
|
159
|
+
// Optional richer status ('error'|'stale'|'running'|'stopping') mirrors the
|
|
160
|
+
// SessionCard STATUS_DISC mapping used on the Live dashboard, so a session
|
|
161
|
+
// pinned to a "Running" rail group reads the same stuck-vs-busy signal it
|
|
162
|
+
// does there rather than only a boolean live dot. Falls back to the plain
|
|
163
|
+
// running dot when no status is supplied (existing callers unaffected).
|
|
164
|
+
s.status
|
|
165
|
+
? h('span', { class: 'status-dot-disc ' + (STATUS_DISC[s.status] || 'status-dot-live'), 'aria-label': STATUS_WORD[s.status] || s.status, role: 'img' })
|
|
166
|
+
: s.running
|
|
167
|
+
? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
|
|
168
|
+
: (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
|
|
169
|
+
].filter(Boolean)),
|
|
170
|
+
(onRename || onDelete) ? h('span', { key: 'row-actions', class: 'ds-session-row-actions' }, [
|
|
171
|
+
onRename ? h('button', { key: 'ra-rn', type: 'button', class: 'ds-session-row-action', 'aria-label': 'rename',
|
|
172
|
+
onclick: (e) => { e.stopPropagation(); onStartRename ? onStartRename(s) : onRename(s, s.title); } }, Icon('pencil', { size: 12 })) : null,
|
|
173
|
+
onDelete ? h('button', { key: 'ra-del', type: 'button', class: 'ds-session-row-action ds-session-row-action-danger', 'aria-label': 'delete',
|
|
174
|
+
onclick: (e) => { e.stopPropagation(); onArmDelete ? onArmDelete(s) : onDelete(s); } }, Icon('trash', { size: 12 })) : null,
|
|
175
|
+
].filter(Boolean)) : null,
|
|
176
|
+
].filter(Boolean);
|
|
177
|
+
}
|
|
178
|
+
const row = h('div', {
|
|
179
|
+
// Stable key: prefer sid, else position - a missing/duplicate sid would make
|
|
180
|
+
// key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
|
|
181
|
+
key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
|
|
182
|
+
role: 'option',
|
|
183
|
+
tabindex: s.sid === selected ? '0' : '-1',
|
|
184
|
+
class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : '') +
|
|
185
|
+
(depth > 0 ? ' ds-session-row-nested' : ''),
|
|
186
|
+
style: depth > 0 ? ('padding-left: calc(var(--space-2) + ' + (depth * 16) + 'px)') : null,
|
|
187
|
+
'aria-selected': s.sid === selected ? 'true' : 'false',
|
|
188
|
+
onclick: (renamingThis || confirmingThis) ? null : () => onSelect && onSelect(s),
|
|
189
|
+
onkeydown: (renamingThis || confirmingThis) ? null : (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect && onSelect(s); } },
|
|
190
|
+
}, ...content);
|
|
191
|
+
if (!hasKids || !kidsOpen) return [row];
|
|
192
|
+
// Depth-first flatten of open children keeps the caller-facing return shape
|
|
193
|
+
// (an array of rows) identical whether tree nesting is on or off.
|
|
194
|
+
const kidRows = childrenBySid.get(s.sid)
|
|
195
|
+
.slice().sort((a, b) => (b.time || '').localeCompare(a.time || ''))
|
|
196
|
+
.flatMap((k, ki) => rowFor(k, ki, depth + 1));
|
|
197
|
+
return [row, ...kidRows];
|
|
198
|
+
};
|
|
106
199
|
|
|
107
200
|
// The body is ALWAYS a single keyed wrapper element of the same tag, so webjsx
|
|
108
201
|
// diffs its children across state transitions (loading -> empty -> populated)
|
|
@@ -126,9 +219,16 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
126
219
|
const bySid = new Map(sessions.map((s) => [s.sid, s]));
|
|
127
220
|
inner = groups.map((g) => h('div', { key: 'g-' + g.label, class: 'ds-session-group', role: 'group', 'aria-label': g.label },
|
|
128
221
|
h('div', { key: 'gl', class: 'ds-session-group-label' }, g.label),
|
|
129
|
-
h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'listbox', 'aria-label': g.label }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).
|
|
222
|
+
h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'listbox', 'aria-label': g.label }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).flatMap((s, i) => rowFor(s, i)))));
|
|
223
|
+
} else if (tree) {
|
|
224
|
+
// Roots = rows with no parentSid, or whose parentSid isn't present in this
|
|
225
|
+
// list (an orphaned fork - the ancestor was deleted/filtered out) - each
|
|
226
|
+
// root's flatMap already walks its open descendants via rowFor's recursion.
|
|
227
|
+
const sidSet = new Set(sessions.map((s) => s.sid));
|
|
228
|
+
const roots = sessions.filter((s) => s.parentSid == null || !sidSet.has(s.parentSid));
|
|
229
|
+
inner = roots.flatMap((s, i) => rowFor(s, i, 0));
|
|
130
230
|
} else {
|
|
131
|
-
inner = sessions.
|
|
231
|
+
inner = sessions.flatMap((s, i) => rowFor(s, i));
|
|
132
232
|
}
|
|
133
233
|
// The load-more row sits INSIDE the scrollable list body (not the outer
|
|
134
234
|
// .ds-sessions shell) so it scrolls with the rows it extends, matching
|