anentrypoint-design 0.0.369 → 0.0.370

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.
@@ -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
- const rowFor = (s, i) => h('div', {
73
- // Stable key: prefer sid, else position - a missing/duplicate sid would make
74
- // key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
75
- key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
76
- role: 'option',
77
- tabindex: s.sid === selected ? '0' : '-1',
78
- class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : ''),
79
- 'aria-selected': s.sid === selected ? 'true' : 'false',
80
- onclick: () => onSelect && onSelect(s),
81
- onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect && onSelect(s); } },
82
- },
83
- // Positional children must NOT mix keyed VElements with null/strings (webjsx
84
- // applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
85
- // each h() call gets a clean, consistent child list.
86
- h('span', { class: 'ds-session-main' }, [
87
- // Two-sided truncation: the CSS ellipsis is paired with a title= carrying
88
- // the full string, so a long title/project is recoverable on hover.
89
- h('span', { class: 'ds-session-title', title: s.title || s.project || s.sid || null }, s.title || s.project || s.sid || ''),
90
- (s.project || s.time) ? h('span', { class: 'ds-session-sub', title: s.project || null },
91
- [s.project, s.time].filter(Boolean).join(' · ')) : null,
92
- ].filter(Boolean)),
93
- h('span', { class: 'ds-session-meta' }, [
94
- s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
95
- // Optional richer status ('error'|'stale'|'running'|'stopping') mirrors the
96
- // SessionCard STATUS_DISC mapping used on the Live dashboard, so a session
97
- // pinned to a "Running" rail group reads the same stuck-vs-busy signal it
98
- // does there rather than only a boolean live dot. Falls back to the plain
99
- // running dot when no status is supplied (existing callers unaffected).
100
- s.status
101
- ? h('span', { class: 'status-dot-disc ' + (STATUS_DISC[s.status] || 'status-dot-live'), 'aria-label': STATUS_WORD[s.status] || s.status, role: 'img' })
102
- : s.running
103
- ? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
104
- : (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
105
- ].filter(Boolean)));
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).map(rowFor))));
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.map(rowFor);
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