anentrypoint-design 0.0.201 → 0.0.202

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.
@@ -31,8 +31,11 @@ export function fileGlyph(type) {
31
31
  return TYPE_ICON[type] || TYPE_ICON.other;
32
32
  }
33
33
 
34
+ // The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
35
+ // format everywhere: '0 B' for zero; the em-dash means unknown/null ONLY.
34
36
  export function fmtFileSize(bytes) {
35
- if (bytes == null || bytes === 0) return '—';
37
+ if (bytes == null) return '—';
38
+ if (bytes === 0) return '0 B';
36
39
  const u = ['B', 'KB', 'MB', 'GB', 'TB'];
37
40
  let i = 0, n = bytes;
38
41
  while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
@@ -278,11 +281,27 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
278
281
  );
279
282
  }
280
283
 
281
- export function UploadProgress({ items = [] } = {}) {
284
+ // UploadProgress per-file upload rows. Error rows are recoverable, not dead
285
+ // ends: each item may carry `actions` ([{ label, onClick }], e.g. 'replace' on
286
+ // a 409 collision) and the host may wire `onDismiss(item, index)` so error rows
287
+ // can be cleared without waiting for the next successful batch.
288
+ export function UploadProgress({ items = [], onDismiss } = {}) {
282
289
  if (!items.length) return null;
283
290
  return h('div', { class: 'ds-upload-progress' },
284
291
  ...items.map((it, i) => {
285
292
  const status = it.error ? 'error' : (it.done ? 'complete' : `uploading ${it.pct || 0}%`);
293
+ const rowActions = [
294
+ ...((it.actions || []).map((a, ai) => h('button', {
295
+ key: 'ua' + ai, type: 'button', class: 'ds-upload-act',
296
+ 'aria-label': `${a.label} ${it.name}`,
297
+ onclick: () => a.onClick && a.onClick(it, i),
298
+ }, a.label))),
299
+ (it.error && onDismiss) ? h('button', {
300
+ key: 'ud', type: 'button', class: 'ds-upload-act',
301
+ 'aria-label': `dismiss ${it.name}`,
302
+ onclick: () => onDismiss(it, i),
303
+ }, 'dismiss') : null,
304
+ ].filter(Boolean);
286
305
  return h('div', {
287
306
  key: it.name + i,
288
307
  class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
@@ -297,7 +316,8 @@ export function UploadProgress({ items = [] } = {}) {
297
316
  h('span', { class: 'ds-upload-bar' },
298
317
  h('span', { class: 'ds-upload-fill', 'data-pct': String(Math.max(0, Math.min(100, it.pct || 0))), 'aria-hidden': 'true' })
299
318
  ),
300
- h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (it.pct || 0) + '%')))
319
+ h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (it.pct || 0) + '%'))),
320
+ rowActions.length ? h('span', { class: 'ds-upload-actions', role: 'group', 'aria-label': `actions for ${it.name}` }, ...rowActions) : null
301
321
  );
302
322
  })
303
323
  );
@@ -8,6 +8,19 @@ import { Btn, Icon } from './shell.js';
8
8
  import { Select, SearchInput } from './content.js';
9
9
  const h = webjsx.createElement;
10
10
 
11
+ // ONE duration format for every surface (live cards, running panel, session
12
+ // meta, context pane): <60s -> 'Ns', <1h -> 'Nm Ss', else 'Nh Nm'. Durations
13
+ // roll s -> m -> h instead of an hour-long run reading '3712s'.
14
+ export function fmtDuration(ms) {
15
+ if (ms == null || !isFinite(ms) || ms < 0) return '';
16
+ const s = Math.round(ms / 1000);
17
+ if (s < 60) return s + 's';
18
+ const m = Math.floor(s / 60);
19
+ if (m < 60) return m + 'm ' + (s % 60) + 's';
20
+ const hrs = Math.floor(m / 60);
21
+ return hrs + 'h ' + (m % 60) + 'm';
22
+ }
23
+
11
24
  // ConversationList — the Claude-Desktop "Chats" column. Sessions grouped by a
12
25
  // caller-supplied group label, each row showing title/project, relative time,
13
26
  // agent badge, and a running/new-event indicator. Selecting a row switches the
@@ -36,8 +49,10 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
36
49
  // applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
37
50
  // each h() call gets a clean, consistent child list.
38
51
  h('span', { class: 'ds-session-main' }, [
39
- h('span', { class: 'ds-session-title' }, s.title || s.project || s.sid || ''),
40
- (s.project || s.time) ? h('span', { class: 'ds-session-sub' },
52
+ // Two-sided truncation: the CSS ellipsis is paired with a title= carrying
53
+ // the full string, so a long title/project is recoverable on hover.
54
+ h('span', { class: 'ds-session-title', title: s.title || s.project || s.sid || null }, s.title || s.project || s.sid || ''),
55
+ (s.project || s.time) ? h('span', { class: 'ds-session-sub', title: s.project || null },
41
56
  [s.project, s.time].filter(Boolean).join(' · ')) : null,
42
57
  ].filter(Boolean)),
43
58
  h('span', { class: 'ds-session-meta' }, [
@@ -125,36 +140,50 @@ export function SessionMeta({ items = [] } = {}) {
125
140
  // no current tool) — it reads as `idle` with a NON-pulsing disc so a stuck agent
126
141
  // is visually distinct from a busy one (a frozen elapsed alone reads identically
127
142
  // for both, which is the high-severity oversight gap this closes).
128
- const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running' };
129
- const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live' };
143
+ // `session.stopping` is the in-flight cancel state: the stop button disables
144
+ // with label 'stopping…' and the status word flips to 'stopping', so the click
145
+ // visibly took and cannot re-fire while the host waits for the active poll.
146
+ // `session.external` marks a session we observe (ccsniff stream) but do not own
147
+ // (no process to kill): the stop button is suppressed, an 'external' tag renders
148
+ // in the head, and the host wires onView to open it in history instead.
149
+ // `session.title` is the SAME string the conversation rails use, rendered as
150
+ // the card heading so the rail row and its dashboard card share one identity.
151
+ // `session.elapsedMs` (raw ms) is formatted internally via fmtDuration; the
152
+ // pre-formatted `elapsed` string remains as a legacy fallback.
153
+ const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running', stopping: 'stopping' };
154
+ const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live', stopping: 'status-dot-connecting' };
130
155
 
131
156
  export function SessionCard({ session = {}, onStop, onOpen, onView, active = false,
132
157
  selectable = false, selected = false, onToggleSelect } = {}) {
133
158
  const s = session;
134
- const st = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running');
159
+ const st = s.stopping ? 'stopping' : (s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running'));
135
160
  // The stat line composes elapsed + live counter; the activity line carries the
136
161
  // last-activity time and the current tool so a card shows MOTION, not just a
137
162
  // start offset. Both are middot-joined (kept product separator).
138
- const statBits = [s.elapsed != null ? s.elapsed : null, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
163
+ const elapsedText = s.elapsedMs != null ? fmtDuration(s.elapsedMs) : (s.elapsed != null ? s.elapsed : null);
164
+ const statBits = [elapsedText, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
139
165
  const activityBits = [
140
166
  s.currentTool ? 'running: ' + s.currentTool : null,
141
167
  s.lastActivity ? 'last ' + s.lastActivity : null,
142
168
  ].filter(Boolean);
143
- const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '');
144
- return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.agent || s.sid), 'aria-current': active ? 'true' : null },
169
+ const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + (s.external ? ' is-external' : '');
170
+ return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.title || s.agent || s.sid), 'aria-current': active ? 'true' : null },
171
+ // Shared session identity: the same title the conversation rails show.
172
+ s.title ? h('div', { class: 'ds-dash-title', title: s.title }, s.title) : null,
145
173
  h('div', { class: 'ds-dash-card-head' },
146
174
  selectable ? h('button', {
147
175
  type: 'button', class: 'ds-dash-select', role: 'checkbox',
148
176
  'aria-checked': selected ? 'true' : 'false',
149
- 'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.agent || s.sid),
177
+ 'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.title || s.agent || s.sid),
150
178
  onclick: () => onToggleSelect && onToggleSelect(s),
151
179
  }, selected ? '[x]' : '[ ]') : null,
152
180
  h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
153
181
  // Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
154
182
  // aria-hidden, so the visible/AT status word carries the state.
155
183
  h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
156
- h('span', { class: 'ds-dash-agent' }, s.agent || 'agent'),
157
- s.model ? h('span', { class: 'ds-dash-model' }, s.model) : null),
184
+ s.external ? h('span', { class: 'ds-dash-external' }, 'external') : null,
185
+ h('span', { class: 'ds-dash-agent', title: s.agent || null }, s.agent || 'agent'),
186
+ s.model ? h('span', { class: 'ds-dash-model', title: s.model }, s.model) : null),
158
187
  h('div', { class: 'ds-dash-meta' },
159
188
  s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
160
189
  statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
@@ -163,8 +192,10 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
163
192
  // open and resume collapsed into one 'open' action (they both just reopen
164
193
  // the session in chat); 'events' kept for the read-only event view.
165
194
  onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
166
- onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
167
- onStop ? Btn({ key: 'stop', danger: true, onClick: () => onStop(s), children: 'stop' }) : null));
195
+ onView ? Btn({ key: 'view', onClick: () => onView(s), children: s.external ? 'open in history' : 'events' }) : null,
196
+ // External sessions get no stop control: we own no process to kill.
197
+ (onStop && !s.external) ? Btn({ key: 'stop', danger: true, disabled: !!s.stopping,
198
+ onClick: () => !s.stopping && onStop(s), children: s.stopping ? 'stopping…' : 'stop' }) : null));
168
199
  }
169
200
 
170
201
  // SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
@@ -177,7 +208,14 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
177
208
  // card's stop. Rendered only when there are sessions AND onStopAll is wired.
178
209
  // Streamstate words: the live-stream health signal so "connected, zero running"
179
210
  // still tells the user the dashboard is listening (vs a dropped stream).
180
- const STREAM_WORD = { connected: 'listening for activity', connecting: 'connecting to live stream…', lost: 'live stream lost — retrying…' };
211
+ // One connection vocabulary across the crumb, settings chip, and the dashboard
212
+ // stream line: connected / connecting / offline ('lost' kept as a legacy alias).
213
+ const STREAM_WORD = {
214
+ connected: 'listening for activity',
215
+ connecting: 'connecting to live stream…',
216
+ offline: 'live stream offline — retrying…',
217
+ lost: 'live stream offline — retrying…',
218
+ };
181
219
 
182
220
  // The stop-all / stop-selected danger buttons are two-step (host-driven, the kit
183
221
  // is stateless): the first click fires onArmStop* so the host flips confirming*
@@ -196,6 +234,9 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
196
234
  }
197
235
  const selSet = selected instanceof Set ? selected : new Set(selected || []);
198
236
  const selCount = selSet.size;
237
+ // While any session is mid-cancel the bulk control reads disabled
238
+ // 'stopping N…' so a bulk stop visibly takes instead of staying re-firable.
239
+ const stoppingCount = sessions.filter((s) => s.stopping).length;
199
240
  // The stream-state line always renders (even with zero sessions) so a
200
241
  // connected-but-idle dashboard reads differently from an offline one.
201
242
  const streamLine = streamState
@@ -225,7 +266,9 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
225
266
  selectable && selCount ? selCount + ' selected' : sessions.length + ' running'),
226
267
  streamLine,
227
268
  h('span', { class: 'spread' }),
228
- selectable && selCount && onStopSelected
269
+ stoppingCount > 0 && (onStopSelected || onStopAll)
270
+ ? Btn({ key: 'stopbusy', danger: true, disabled: true, children: 'stopping ' + stoppingCount + '…' })
271
+ : (selectable && selCount && onStopSelected
229
272
  ? (onArmStopSelected && !confirmingStopSelected
230
273
  ? Btn({ key: 'stopsel', danger: true, onClick: () => onArmStopSelected([...selSet]), children: 'stop selected' })
231
274
  : Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]),
@@ -235,7 +278,7 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
235
278
  ? Btn({ key: 'stopall', danger: true, onClick: () => onArmStopAll(sessions), children: 'stop all' })
236
279
  : Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions),
237
280
  children: confirmingStopAll ? 'stop ' + sessions.length + ' sessions - press again' : 'stop all' }))
238
- : null),
281
+ : null)),
239
282
  toolbar);
240
283
  const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
241
284
  ...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
package/src/components.js CHANGED
@@ -20,15 +20,15 @@ export {
20
20
  } from './components/content.js';
21
21
 
22
22
  export {
23
- fmtBytes, renderInline,
23
+ fmtBytes, renderInline, hasSelectionInside,
24
24
  ChatMessage, ChatComposer, Chat,
25
25
  AICAT_FACE, AICatPortrait, AICat
26
26
  } from './components/chat.js';
27
27
 
28
- export { AgentChat } from './components/agent-chat.js';
28
+ export { AgentChat, MESSAGE_CAP } from './components/agent-chat.js';
29
29
 
30
30
  export {
31
- ConversationList, SessionCard, SessionDashboard, SessionMeta
31
+ ConversationList, SessionCard, SessionDashboard, SessionMeta, fmtDuration
32
32
  } from './components/sessions.js';
33
33
 
34
34
  export { ContextPane } from './components/context-pane.js';
@@ -2,7 +2,7 @@
2
2
  // Ensures libraries are loaded once globally, reused for all subsequent renders.
3
3
  // Tracks initialization status and render timings.
4
4
 
5
- import { renderMarkdown, ensureReady as ensureMarkdownReady } from './markdown.js';
5
+ import { renderMarkdown, ensureReady as ensureMarkdownReady, isDegraded as isMarkdownDegraded } from './markdown.js';
6
6
  import { highlightAllUnder, ensurePrism } from './highlight.js';
7
7
  import { register } from './debug.js';
8
8
 
@@ -84,19 +84,23 @@ export async function renderMarkdownCached(text) {
84
84
  return _renderCache.get(hash);
85
85
  }
86
86
 
87
- // Ensure markdown is ready (cached after first init)
88
- if (!_markdownInitialized) {
89
- await ensureMarkdownReady();
90
- _markdownInitialized = true;
91
- }
87
+ // Ensure markdown is ready. NOT latched behind _markdownInitialized: a
88
+ // failed loader must be retried on a later render (markdown.js owns the
89
+ // retry backoff), otherwise an offline boot is sticky-degraded forever.
90
+ await ensureMarkdownReady();
91
+ _markdownInitialized = !isMarkdownDegraded();
92
92
 
93
93
  const html = await renderMarkdown(text);
94
94
 
95
- // Store in content cache (limit to 500 entries to prevent unbounded growth)
96
- _renderCache.set(hash, html);
97
- if (_renderCache.size > 500) {
98
- const first = _renderCache.keys().next().value;
99
- _renderCache.delete(first);
95
+ // Store in content cache (limit to 500 entries to prevent unbounded growth).
96
+ // Never cache degraded (escaped-fallback) output: when the loader recovers,
97
+ // the same content must re-render as real markdown, not replay the fallback.
98
+ if (!isMarkdownDegraded()) {
99
+ _renderCache.set(hash, html);
100
+ if (_renderCache.size > 500) {
101
+ const first = _renderCache.keys().next().value;
102
+ _renderCache.delete(first);
103
+ }
100
104
  }
101
105
 
102
106
  const renderMs = performance.now() - t0;
package/src/markdown.js CHANGED
@@ -5,20 +5,35 @@
5
5
  let _ready = null;
6
6
  let _marked = null;
7
7
  let _purify = null;
8
+ // A failed load is NOT cached forever: we drop _ready so a later render retries,
9
+ // guarded by a small backoff so an offline session doesn't hammer the CDN.
10
+ let _failedAt = 0;
11
+ const RETRY_BACKOFF_MS = 30000;
8
12
 
9
13
  const MARKED_URL = 'https://cdn.jsdelivr.net/npm/marked@15/+esm';
10
14
  const PURIFY_URL = 'https://cdn.jsdelivr.net/npm/dompurify@3/+esm';
11
15
 
16
+ // True while the markdown stack is unavailable (escaped-fallback rendering).
17
+ // Consumers (markdown-cache) use this to avoid caching degraded output.
18
+ export function isDegraded() {
19
+ return !_marked || !_purify;
20
+ }
21
+
12
22
  export async function ensureReady() {
13
23
  if (_ready) return _ready;
24
+ if (_failedAt && Date.now() - _failedAt < RETRY_BACKOFF_MS) return false;
14
25
  _ready = (async () => {
15
26
  try {
16
27
  const [{ marked }, DOMPurifyMod] = await Promise.all([import(MARKED_URL), import(PURIFY_URL)]);
17
28
  _marked = marked;
18
29
  _purify = DOMPurifyMod.default || DOMPurifyMod;
30
+ _failedAt = 0;
19
31
  return true;
20
32
  } catch (err) {
21
33
  console.warn('[247420] markdown loader failed:', err);
34
+ // Reset the cached promise so a later render retries (after backoff).
35
+ _ready = null;
36
+ _failedAt = Date.now();
22
37
  return false;
23
38
  }
24
39
  })();