@aryacode/dsh-aryacode-polish 0.3.7 → 0.3.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.9] - 2026-10-27
4
+
5
+ ### Changed
6
+
7
+ - **The cost pill now looks like it belongs in the composer.** It was a foreign
8
+ widget next to the harness pills: two lines of text ("≈$0.538 partially
9
+ priced") in the row's flow. It now wears the same skin as the statistics pills
10
+ — the same 13 px-minus-one scale, 20 px line height, `1px 8px` padding, tertiary
11
+ label colour, tabular digits, a 14 px glyph, and `white-space: nowrap`, so it
12
+ reads as one line at the same size as its neighbours.
13
+ - The "partially priced" caveat moved out of the pill and into the card, where it
14
+ can be read. The pill keeps a single `*` marker for the same fact.
15
+ - The breakdown card is now the harness's own stat-dialog: same panel width
16
+ clamp, 16 px padding, `--dsw-radius-lg` corners, menu backdrop, prominent
17
+ elevation, 12/18 type, a two-column definition list with tabular figures, and
18
+ a portaled `position: fixed` panel measured from the pill so it cannot be
19
+ clipped by the composer scroller. It closes on outside click and on Escape.
20
+
21
+ ## [0.3.8] - 2026-10-27
22
+
23
+ ### Fixed
24
+
25
+ - **The balance line never showed a number.** It waited for the host account
26
+ bundle's own access grant before reading, on the theory that the grant proves
27
+ the call is allowed. That grant belongs to the bundle that owns the account
28
+ stream, so for a plugin it can simply never arrive — the read hit its timeout,
29
+ resolved to nothing, and the sidebar looked empty with no error anywhere. The
30
+ line now reads the account Remote directly inside
31
+ `ctx.inject(['remote', 'remote.account'], …)`, and the Host still decides: an
32
+ unauthorised call comes back refused and the line stays hidden.
33
+ - The read retries on its own (1.5 s, 5 s, 15 s, 30 s, and again whenever the
34
+ window regains focus), so a Shell that is still signing in at first paint no
35
+ longer leaves a permanently blank line.
36
+
3
37
  ## [0.3.7] - 2026-10-27
4
38
 
5
39
  ### Fixed
package/client/account.js CHANGED
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * Reading the account balance from a plugin face.
3
3
  *
4
- * The account Remote methods are gated, so the read runs inside a
5
- * `remoteAccess.whenAccess('account/watch', …)` grant and the grant is released
6
- * as soon as the read settles. Every failure — no service in this Shell, a
7
- * refused grant, a failed call — resolves to `undefined` instead of throwing: a
8
- * sidebar line is never worth breaking the shell over, and a line that stays
9
- * empty is honest about a number it never received.
4
+ * The account Remote methods are the same ones the shipped account row uses. An
5
+ * earlier version waited for the host account bundle's own access grant before
6
+ * reading, on the theory that the grant proves the call is allowed. It does not:
7
+ * that grant belongs to the bundle that owns the account stream, and a plugin
8
+ * that waits for it can wait forever — the sidebar line then stays empty with no
9
+ * error anywhere, which is exactly what a user reports as "I don't see the
10
+ * balance".
11
+ *
12
+ * So this reads directly. The Host still decides: an unauthorised call comes back
13
+ * `{ ok: false }` (or throws) and this resolves to `undefined`. Every failure — no
14
+ * account namespace in this Shell, a refused call, a failed call — resolves to
15
+ * `undefined` instead of throwing, because a sidebar line is never worth breaking
16
+ * the shell over.
10
17
  *
11
18
  * This module is inlined into `lib/client.js` by `scripts/build-client.mjs` and
12
19
  * imported directly by `test/account.spec.mjs`, so the tested source is the one
@@ -35,62 +42,48 @@ export function clientMetadata(version) {
35
42
  }
36
43
 
37
44
  /**
38
- * Build the one-shot balance reader.
45
+ * The account Remote namespace, or undefined when this Shell has none.
39
46
  *
40
- * @param ctx - the browser plugin context; `ctx.get` resolves an optional service.
41
- * @param version - the version this build stamps into the account metadata.
42
- * @returns a reader that resolves to the balance view, or undefined when unavailable.
47
+ * @param ctx - the browser plugin context; the balance surface is registered
48
+ * inside `ctx.inject(['remote', 'remote.account'], …)` so this normally resolves.
49
+ * @returns the namespace, or undefined.
43
50
  */
44
- export function makeBalanceReader(ctx, version, { timeoutMs = 10000 } = {}) {
45
- const service = name => {
46
- try {
47
- return ctx.get(name)
48
- } catch {
49
- return undefined
50
- }
51
+ export function accountService(ctx) {
52
+ try {
53
+ const remote = ctx.remote !== undefined ? ctx.remote : ctx.get('remote')
54
+ const account = remote !== undefined && remote !== null ? remote.account : undefined
55
+ return account !== undefined && account !== null && typeof account.getBalance === 'function' ? account : undefined
56
+ } catch {
57
+ return undefined
51
58
  }
52
- return async () => {
53
- const access = service('remoteAccess')
54
- const remote = service('remote')
55
- if (access === undefined || remote === undefined) return undefined
56
- const metadata = clientMetadata(version)
57
- return await new Promise(resolve => {
58
- let settled = false
59
- let stop
60
- const timer = setTimeout(() => finish(undefined), timeoutMs)
61
- const finish = value => {
62
- if (settled) return
63
- settled = true
64
- clearTimeout(timer)
65
- resolve(value)
66
- if (typeof stop === 'function') {
67
- try {
68
- stop()
69
- } catch {
70
- /* the grant may already have been released */
71
- }
72
- }
73
- }
74
- // The read starts before the yield so an effect runner that stops at the
75
- // first yielded disposer still performs the call.
76
- stop = access.whenAccess('account/watch', function* () {
77
- void (async () => {
78
- try {
79
- const result = await remote.account.getBalance(metadata)
80
- const value = result !== undefined && result.ok === true ? result.value : undefined
81
- finish(value !== undefined && value.status === 'ready' ? value : undefined)
82
- } catch {
83
- finish(undefined)
84
- }
85
- })()
86
- yield () => {
87
- if (!settled) {
88
- settled = true
89
- clearTimeout(timer)
90
- resolve(undefined)
91
- }
92
- }
93
- })
94
- })
59
+ }
60
+
61
+ /**
62
+ * Read the balance once.
63
+ *
64
+ * @param ctx - the browser plugin context.
65
+ * @param version - the version this build stamps into the account metadata.
66
+ * @param options - `timeoutMs` bounds a call that never settles.
67
+ * @returns the ready balance view, or undefined when there is no number to show.
68
+ */
69
+ export async function readBalance(ctx, version, { timeoutMs = 15000 } = {}) {
70
+ const account = accountService(ctx)
71
+ if (account === undefined) return undefined
72
+ const call = (async () => {
73
+ const result = await account.getBalance(clientMetadata(version))
74
+ if (result === undefined || result === null || result.ok !== true) return undefined
75
+ const value = result.value
76
+ return value !== undefined && value !== null && value.status === 'ready' ? value : undefined
77
+ })().catch(() => undefined)
78
+ let timer
79
+ const timeout = new Promise(resolve => {
80
+ timer = setTimeout(() => resolve(undefined), timeoutMs)
81
+ // A pending balance read must not keep a Node process alive during tests.
82
+ if (typeof timer === 'object' && timer !== null && typeof timer.unref === 'function') timer.unref()
83
+ })
84
+ try {
85
+ return await Promise.race([call, timeout])
86
+ } finally {
87
+ clearTimeout(timer)
95
88
  }
96
89
  }
package/client/face.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  const React = require('react')
18
+ const ReactDOM = require('react-dom')
18
19
 
19
20
  /** Shared spacing for the page's prose. */
20
21
  const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
@@ -184,23 +185,131 @@ function formatTokens(value) {
184
185
  return Number.isFinite(count) ? new Intl.NumberFormat('en-US').format(count) + ' tok' : ''
185
186
  }
186
187
 
187
- /** One label/value line of the breakdown card. */
188
+ /**
189
+ * One label/value line of the breakdown card.
190
+ *
191
+ * Native stat dialogs are a two-column `<dl>`; a flex div per row looks the same
192
+ * until the labels differ in width, so this emits the grid's own children.
193
+ *
194
+ * @param label - the row name.
195
+ * @param value - the reading.
196
+ * @param key - React key.
197
+ * @returns the `dt`/`dd` pair.
198
+ */
188
199
  function detailRow(label, value, key) {
189
- return React.createElement('div', {
190
- key,
191
- style: { display: 'flex', justifyContent: 'space-between', gap: '16px', padding: '2px 0' },
192
- },
193
- React.createElement('span', { style: { opacity: 0.75 } }, label),
194
- React.createElement('span', null, value))
200
+ return React.createElement(React.Fragment, { key },
201
+ React.createElement('dt', null, label),
202
+ React.createElement('dd', null, value))
195
203
  }
196
204
 
197
205
  /** The small text under the card that keeps the number honest. */
198
206
  const COST_FOOTNOTE = 'Based on recorded usage and published rates. Your provider\'s bill is authoritative.'
199
207
 
208
+ /**
209
+ * The dock pill's skin, copied from the harness statistics pills
210
+ * (`StatsPills.module.css`) so this one sits in the row instead of looking
211
+ * foreign: same size, same tertiary color, same 14px glyph, never wrapped.
212
+ */
213
+ const PILL_ANCHOR_STYLE = {
214
+ display: 'inline-flex',
215
+ minWidth: 0,
216
+ fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)',
217
+ lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
218
+ }
219
+
220
+ /** The resting pill; hover and open states swap only background and color. */
221
+ const PILL_STYLE = {
222
+ display: 'inline-flex',
223
+ alignItems: 'center',
224
+ gap: '6px',
225
+ boxSizing: 'border-box',
226
+ maxWidth: '100%',
227
+ padding: '1px 8px',
228
+ border: 'none',
229
+ borderRadius: 'var(--dsw-radius-sm, 4px)',
230
+ background: 'transparent',
231
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
232
+ font: 'inherit',
233
+ fontVariantNumeric: 'tabular-nums',
234
+ lineHeight: 'inherit',
235
+ whiteSpace: 'nowrap',
236
+ cursor: 'pointer',
237
+ }
238
+
239
+ /** The label half of the pill, cut with an ellipsis rather than wrapped. */
240
+ const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
241
+
242
+ /** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
243
+ const PANEL_STYLE = {
244
+ position: 'fixed',
245
+ zIndex: 1100,
246
+ boxSizing: 'border-box',
247
+ width: 'max-content',
248
+ minWidth: 'min(300px, calc(100vw - 24px))',
249
+ maxWidth: 'min(440px, calc(100vw - 24px))',
250
+ padding: '16px',
251
+ border: 0,
252
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
253
+ background: 'var(--dsw-specific-menu, Canvas)',
254
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
255
+ boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
256
+ fontSize: '12px',
257
+ lineHeight: '18px',
258
+ color: 'var(--dsw-alias-label-secondary, inherit)',
259
+ cursor: 'default',
260
+ }
261
+
262
+ /** Dialog heading row: section name left, headline value right. */
263
+ const PANEL_TITLE_STYLE = {
264
+ display: 'flex',
265
+ justifyContent: 'space-between',
266
+ gap: '16px',
267
+ marginBottom: '8px',
268
+ color: 'var(--dsw-alias-label-primary, inherit)',
269
+ fontWeight: 500,
270
+ }
271
+
272
+ /** The rule under a section heading. */
273
+ const PANEL_RULE_STYLE = { marginBottom: '10px', borderTop: '0.5px solid var(--dsw-alias-border-l2, currentColor)' }
274
+
275
+ /** The two-column readings grid. */
276
+ const PANEL_DETAILS_STYLE = {
277
+ display: 'grid',
278
+ gridTemplateColumns: 'minmax(76px, auto) minmax(0, 1fr)',
279
+ gap: '6px 16px',
280
+ margin: 0,
281
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
282
+ }
283
+
284
+ /**
285
+ * A 14px coin glyph, the same weight as the harness pill icons. Drawn inline
286
+ * because a plugin face cannot require the icon package without adding it to the
287
+ * frozen module list.
288
+ * @returns the svg element.
289
+ */
290
+ function CoinGlyph() {
291
+ return React.createElement('svg', {
292
+ width: 14,
293
+ height: 14,
294
+ viewBox: '0 0 16 16',
295
+ fill: 'none',
296
+ stroke: 'currentColor',
297
+ strokeWidth: 1.3,
298
+ 'aria-hidden': true,
299
+ style: { flex: 'none' },
300
+ },
301
+ React.createElement('circle', { cx: 8, cy: 8, r: 6.2 }),
302
+ React.createElement('path', { d: 'M9.6 6.1c-.35-.5-1.85-.86-2.6-.3-.9.66-.35 1.5.75 1.72 1.1.22 2.1.5 1.85 1.5-.3 1.2-2.6 1.3-3.4.5' }),
303
+ React.createElement('path', { d: 'M8 4.4v7.2' }))
304
+ }
305
+
200
306
  /**
201
307
  * The composer-dock pill, and the breakdown card it opens.
308
+ *
202
309
  * Reads the priced projection the Host registers, so no rate table or money
203
- * arithmetic runs in the browser.
310
+ * arithmetic runs in the browser. The pill is skinned from the harness's own
311
+ * statistics pill so it joins that row instead of standing out of it.
312
+ *
204
313
  * @param props - standard session props plus our lazy `configForms`.
205
314
  * @returns the pill, or nothing while the feature is off or the session has no usage.
206
315
  */
@@ -208,22 +317,49 @@ function CostPill(props) {
208
317
  const switched = useSwitch(props.configForms, 'sessionCost', true)
209
318
  const usage = typeof props.useProjection === 'function' ? props.useProjection('polishCost') : undefined
210
319
  const [open, setOpen] = React.useState(false)
320
+ const [hovered, setHovered] = React.useState(false)
321
+ const [pos, setPos] = React.useState(null)
211
322
  const root = React.useRef(null)
323
+ const panel = React.useRef(null)
324
+
325
+ // Anchored like the native dialogs: fixed in the viewport, clamped to a 12px
326
+ // margin so a pill near the window edge cannot push the card off screen.
327
+ React.useEffect(() => {
328
+ if (!open) {
329
+ setPos(null)
330
+ return undefined
331
+ }
332
+ if (root.current === null || typeof window === 'undefined') return undefined
333
+ const rect = root.current.getBoundingClientRect()
334
+ const width = Math.min(440, window.innerWidth - 24)
335
+ setPos({
336
+ left: Math.max(12, Math.min(rect.left, window.innerWidth - 12 - width)),
337
+ bottom: Math.max(12, window.innerHeight - rect.top + 8),
338
+ })
339
+ return undefined
340
+ }, [open])
212
341
 
213
342
  React.useEffect(() => {
214
343
  if (!open) return undefined
215
- const close = event => {
216
- if (root.current !== null && !root.current.contains(event.target)) setOpen(false)
344
+ const onPointer = event => {
345
+ const insidePill = root.current !== null && root.current.contains(event.target)
346
+ const insidePanel = panel.current !== null && panel.current.contains(event.target)
347
+ if (!insidePill && !insidePanel) setOpen(false)
348
+ }
349
+ const onKey = event => { if (event.key === 'Escape') setOpen(false) }
350
+ document.addEventListener('pointerdown', onPointer)
351
+ document.addEventListener('keydown', onKey)
352
+ return () => {
353
+ document.removeEventListener('pointerdown', onPointer)
354
+ document.removeEventListener('keydown', onKey)
217
355
  }
218
- document.addEventListener('pointerdown', close)
219
- return () => document.removeEventListener('pointerdown', close)
220
356
  }, [open])
221
357
 
222
358
  if (!switched) return null
223
359
  const tokens = typeof usage?.tokens === 'number' ? usage.tokens : 0
224
360
  if (tokens === 0) return null
225
361
  const money = formatMoney(usage.costUsd)
226
- const label = money === '' ? 'Not priced' : (usage.incomplete ? '≈' + money + ' partially priced' : '≈' + money)
362
+ const unpriced = usage.incomplete === true || money === ''
227
363
  const cacheHit = tokens === 0 ? 0 : Math.round((usage.cacheRead ?? 0) / tokens * 100)
228
364
  const models = Array.isArray(usage.models) ? usage.models : []
229
365
 
@@ -245,7 +381,7 @@ function CostPill(props) {
245
381
  style: {
246
382
  display: 'flex',
247
383
  height: '6px',
248
- margin: '10px 0 12px',
384
+ margin: '0 0 12px',
249
385
  borderRadius: '999px',
250
386
  overflow: 'hidden',
251
387
  background: 'color-mix(in srgb, CanvasText 10%, transparent)',
@@ -256,49 +392,27 @@ function CostPill(props) {
256
392
  segment(usage.cacheWrite, 62, 'bar-cache-write'),
257
393
  segment(usage.output, 82, 'bar-output'))
258
394
 
259
- return React.createElement('div', {
260
- ref: root,
261
- style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
395
+ const reading = money === '' ? 'Not priced' : '≈' + money
396
+ const full = reading + (unpriced ? ' — partially priced: some usage has no published rate' : '')
397
+ const pillStyle = { ...PILL_STYLE, ...(hovered || open
398
+ ? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.12))', color: 'var(--dsw-alias-label-secondary, inherit)' }
399
+ : {}) }
400
+
401
+ const card = React.createElement('div', {
402
+ ref: panel,
403
+ role: 'dialog',
404
+ 'aria-label': 'Estimated session cost',
405
+ 'data-polish-cost-card': '',
406
+ style: { ...PANEL_STYLE, ...(pos === null ? { left: 12, bottom: 12, visibility: 'hidden' } : pos) },
262
407
  },
263
- React.createElement('button', {
264
- type: 'button',
265
- onClick: () => setOpen(!open),
266
- title: 'Estimated session cost',
267
- style: {
268
- background: 'transparent',
269
- border: 'none',
270
- color: 'inherit',
271
- cursor: 'pointer',
272
- font: 'inherit',
273
- padding: '0 2px',
274
- opacity: 0.8,
275
- },
276
- }, label),
277
- open
278
- ? React.createElement('div', {
279
- style: {
280
- position: 'absolute',
281
- bottom: '100%',
282
- left: 0,
283
- marginBottom: '8px',
284
- width: '320px',
285
- padding: '14px',
286
- borderRadius: '10px',
287
- border: '1px solid color-mix(in srgb, CanvasText 16%, transparent)',
288
- background: 'Canvas',
289
- color: 'CanvasText',
290
- boxShadow: '0 10px 30px color-mix(in srgb, CanvasText 18%, transparent)',
291
- fontSize: '12px',
292
- lineHeight: '1.5',
293
- zIndex: 40,
294
- },
295
- },
296
- React.createElement('div', {
297
- style: { display: 'flex', justifyContent: 'space-between', gap: '16px', fontWeight: 500 },
298
- },
299
- React.createElement('span', null, 'Estimated session cost'),
300
- React.createElement('span', null, label)),
301
- bar,
408
+ React.createElement('div', { style: PANEL_TITLE_STYLE },
409
+ React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
410
+ React.createElement(CoinGlyph),
411
+ 'Estimated session cost'),
412
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, money === '' ? 'Not priced' : money)),
413
+ React.createElement('div', { style: PANEL_RULE_STYLE, 'aria-hidden': true }),
414
+ bar,
415
+ React.createElement('dl', { style: PANEL_DETAILS_STYLE },
302
416
  detailRow('Cache hit', cacheHit + '%', 'cache-hit'),
303
417
  detailRow('Uncached input', formatTokens(usage.uncached), 'uncached'),
304
418
  detailRow('Cached input', formatTokens(usage.cacheRead), 'cached'),
@@ -306,13 +420,39 @@ function CostPill(props) {
306
420
  detailRow('Output', formatTokens(usage.output), 'output'),
307
421
  detailRow('Model', modelName, 'model'),
308
422
  detailRow('Provider', providerName, 'provider'),
309
- detailRow('Requests', String(usage.requests ?? 0), 'requests'),
310
- React.createElement('div', { style: { borderTop: '1px solid color-mix(in srgb, CanvasText 12%, transparent)', margin: '8px 0' } }),
311
- detailRow('Estimated session cost', money === '' ? 'Not priced' : money, 'cost'),
312
- React.createElement('p', { style: { margin: '10px 0 0', opacity: 0.7, fontSize: '11px' } }, COST_FOOTNOTE))
313
- : null)
423
+ detailRow('Requests', String(usage.requests ?? 0), 'requests')),
424
+ unpriced
425
+ ? React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
426
+ 'Partially priced: some usage in this session has no published rate, so this is a lower bound.')
427
+ : null,
428
+ React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, COST_FOOTNOTE))
429
+
430
+ return React.createElement('span', { ref: root, style: PILL_ANCHOR_STYLE, 'data-composer-stat': 'polish-cost' },
431
+ React.createElement('button', {
432
+ type: 'button',
433
+ className: undefined,
434
+ 'aria-haspopup': 'dialog',
435
+ 'aria-expanded': open,
436
+ 'aria-label': full,
437
+ title: full,
438
+ onClick: () => setOpen(!open),
439
+ onMouseEnter: () => setHovered(true),
440
+ onMouseLeave: () => setHovered(false),
441
+ style: pillStyle,
442
+ },
443
+ React.createElement(CoinGlyph),
444
+ React.createElement('span', { style: PILL_LABEL_STYLE }, reading),
445
+ unpriced ? React.createElement('span', { style: { color: 'var(--dsw-alias-separator-primary, inherit)' } }, '*') : null),
446
+ open && typeof document !== 'undefined' ? ReactDOM.createPortal(card, document.body) : null)
314
447
  }
315
448
 
449
+ /**
450
+ * How long to wait before trying the balance again, in milliseconds. A Shell that
451
+ * has not finished signing in answers the first read with nothing, and a line that
452
+ * gives up after one attempt would stay empty for the life of the page.
453
+ */
454
+ const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
455
+
316
456
  /**
317
457
  * The remaining-balance line in the sidebar foot.
318
458
  *
@@ -330,15 +470,43 @@ function BalanceLine(props) {
330
470
  React.useEffect(() => {
331
471
  if (!enabled || typeof read !== 'function') return undefined
332
472
  let live = true
333
- Promise.resolve()
334
- .then(() => read())
335
- .then(view => {
336
- if (live) setWallet(walletOf(view))
337
- }, () => {
338
- if (live) setWallet(undefined)
339
- })
473
+ let attempt = 0
474
+ let timer
475
+ const look = () => {
476
+ Promise.resolve()
477
+ .then(() => read())
478
+ .then(view => {
479
+ if (!live) return
480
+ const next = walletOf(view)
481
+ if (next !== undefined) {
482
+ setWallet(next)
483
+ return
484
+ }
485
+ if (attempt < BALANCE_RETRY_DELAYS.length) {
486
+ timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
487
+ attempt += 1
488
+ }
489
+ }, () => {
490
+ if (!live) return
491
+ if (attempt < BALANCE_RETRY_DELAYS.length) {
492
+ timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
493
+ attempt += 1
494
+ }
495
+ })
496
+ }
497
+ look()
498
+ // A window that was hidden through sign-in comes back with an answer the
499
+ // first attempt could not have had; retry from the start when it regains focus.
500
+ const onFocus = () => {
501
+ attempt = 0
502
+ clearTimeout(timer)
503
+ look()
504
+ }
505
+ if (typeof window !== 'undefined') window.addEventListener('focus', onFocus)
340
506
  return () => {
341
507
  live = false
508
+ clearTimeout(timer)
509
+ if (typeof window !== 'undefined') window.removeEventListener('focus', onFocus)
342
510
  }
343
511
  }, [enabled, read])
344
512
  if (!enabled) return null
@@ -392,12 +560,16 @@ return {
392
560
  order: 2,
393
561
  inject: () => ({ configForms }),
394
562
  }, CostPill))
395
- const loadBalance = makeBalanceReader(ctx, CLIENT_VERSION)
396
- ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
397
- name: 'sidebar.footer.action',
398
- id: 'polish-balance',
399
- order: 3,
400
- inject: () => ({ configForms, loadBalance }),
401
- }, BalanceLine))
563
+ // The account Remote namespace is optional: a Shell with no account still gets
564
+ // the Configure page and the cost pill, and only loses the balance line.
565
+ ctx.inject(['remote', 'remote.account'], (accountCtx) => {
566
+ const loadBalance = () => readBalance(accountCtx, CLIENT_VERSION)
567
+ accountCtx.slots.inject('sidebar.footer.action', () => accountCtx.slots.register({
568
+ name: 'sidebar.footer.action',
569
+ id: 'polish-balance',
570
+ order: 3,
571
+ inject: () => ({ configForms, loadBalance }),
572
+ }, BalanceLine))
573
+ })
402
574
  },
403
575
  }
package/lib/client.js CHANGED
@@ -23,7 +23,7 @@ window.__ModuleLoader__.load({
23
23
  ]
24
24
  const ROW_ID = "polish"
25
25
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
26
- const CLIENT_VERSION = "0.3.7"
26
+ const CLIENT_VERSION = "0.3.9"
27
27
 
28
28
  /**
29
29
  * The two pure decisions behind the balance line: which wallet to show, and how
@@ -60,12 +60,19 @@ window.__ModuleLoader__.load({
60
60
  /**
61
61
  * Reading the account balance from a plugin face.
62
62
  *
63
- * The account Remote methods are gated, so the read runs inside a
64
- * `remoteAccess.whenAccess('account/watch', …)` grant and the grant is released
65
- * as soon as the read settles. Every failure — no service in this Shell, a
66
- * refused grant, a failed call — resolves to `undefined` instead of throwing: a
67
- * sidebar line is never worth breaking the shell over, and a line that stays
68
- * empty is honest about a number it never received.
63
+ * The account Remote methods are the same ones the shipped account row uses. An
64
+ * earlier version waited for the host account bundle's own access grant before
65
+ * reading, on the theory that the grant proves the call is allowed. It does not:
66
+ * that grant belongs to the bundle that owns the account stream, and a plugin
67
+ * that waits for it can wait forever — the sidebar line then stays empty with no
68
+ * error anywhere, which is exactly what a user reports as "I don't see the
69
+ * balance".
70
+ *
71
+ * So this reads directly. The Host still decides: an unauthorised call comes back
72
+ * `{ ok: false }` (or throws) and this resolves to `undefined`. Every failure — no
73
+ * account namespace in this Shell, a refused call, a failed call — resolves to
74
+ * `undefined` instead of throwing, because a sidebar line is never worth breaking
75
+ * the shell over.
69
76
  *
70
77
  * This module is inlined into `lib/client.js` by `scripts/build-client.mjs` and
71
78
  * imported directly by `test/account.spec.mjs`, so the tested source is the one
@@ -94,63 +101,49 @@ window.__ModuleLoader__.load({
94
101
  }
95
102
 
96
103
  /**
97
- * Build the one-shot balance reader.
104
+ * The account Remote namespace, or undefined when this Shell has none.
98
105
  *
99
- * @param ctx - the browser plugin context; `ctx.get` resolves an optional service.
100
- * @param version - the version this build stamps into the account metadata.
101
- * @returns a reader that resolves to the balance view, or undefined when unavailable.
106
+ * @param ctx - the browser plugin context; the balance surface is registered
107
+ * inside `ctx.inject(['remote', 'remote.account'], …)` so this normally resolves.
108
+ * @returns the namespace, or undefined.
102
109
  */
103
- function makeBalanceReader(ctx, version, { timeoutMs = 10000 } = {}) {
104
- const service = name => {
105
- try {
106
- return ctx.get(name)
107
- } catch {
108
- return undefined
109
- }
110
+ function accountService(ctx) {
111
+ try {
112
+ const remote = ctx.remote !== undefined ? ctx.remote : ctx.get('remote')
113
+ const account = remote !== undefined && remote !== null ? remote.account : undefined
114
+ return account !== undefined && account !== null && typeof account.getBalance === 'function' ? account : undefined
115
+ } catch {
116
+ return undefined
110
117
  }
111
- return async () => {
112
- const access = service('remoteAccess')
113
- const remote = service('remote')
114
- if (access === undefined || remote === undefined) return undefined
115
- const metadata = clientMetadata(version)
116
- return await new Promise(resolve => {
117
- let settled = false
118
- let stop
119
- const timer = setTimeout(() => finish(undefined), timeoutMs)
120
- const finish = value => {
121
- if (settled) return
122
- settled = true
123
- clearTimeout(timer)
124
- resolve(value)
125
- if (typeof stop === 'function') {
126
- try {
127
- stop()
128
- } catch {
129
- /* the grant may already have been released */
130
- }
131
- }
132
- }
133
- // The read starts before the yield so an effect runner that stops at the
134
- // first yielded disposer still performs the call.
135
- stop = access.whenAccess('account/watch', function* () {
136
- void (async () => {
137
- try {
138
- const result = await remote.account.getBalance(metadata)
139
- const value = result !== undefined && result.ok === true ? result.value : undefined
140
- finish(value !== undefined && value.status === 'ready' ? value : undefined)
141
- } catch {
142
- finish(undefined)
143
- }
144
- })()
145
- yield () => {
146
- if (!settled) {
147
- settled = true
148
- clearTimeout(timer)
149
- resolve(undefined)
150
- }
151
- }
152
- })
153
- })
118
+ }
119
+
120
+ /**
121
+ * Read the balance once.
122
+ *
123
+ * @param ctx - the browser plugin context.
124
+ * @param version - the version this build stamps into the account metadata.
125
+ * @param options - `timeoutMs` bounds a call that never settles.
126
+ * @returns the ready balance view, or undefined when there is no number to show.
127
+ */
128
+ async function readBalance(ctx, version, { timeoutMs = 15000 } = {}) {
129
+ const account = accountService(ctx)
130
+ if (account === undefined) return undefined
131
+ const call = (async () => {
132
+ const result = await account.getBalance(clientMetadata(version))
133
+ if (result === undefined || result === null || result.ok !== true) return undefined
134
+ const value = result.value
135
+ return value !== undefined && value !== null && value.status === 'ready' ? value : undefined
136
+ })().catch(() => undefined)
137
+ let timer
138
+ const timeout = new Promise(resolve => {
139
+ timer = setTimeout(() => resolve(undefined), timeoutMs)
140
+ // A pending balance read must not keep a Node process alive during tests.
141
+ if (typeof timer === 'object' && timer !== null && typeof timer.unref === 'function') timer.unref()
142
+ })
143
+ try {
144
+ return await Promise.race([call, timeout])
145
+ } finally {
146
+ clearTimeout(timer)
154
147
  }
155
148
  }
156
149
 
@@ -171,6 +164,7 @@ window.__ModuleLoader__.load({
171
164
  */
172
165
 
173
166
  const React = require('react')
167
+ const ReactDOM = require('react-dom')
174
168
 
175
169
  /** Shared spacing for the page's prose. */
176
170
  const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
@@ -340,23 +334,131 @@ window.__ModuleLoader__.load({
340
334
  return Number.isFinite(count) ? new Intl.NumberFormat('en-US').format(count) + ' tok' : ''
341
335
  }
342
336
 
343
- /** One label/value line of the breakdown card. */
337
+ /**
338
+ * One label/value line of the breakdown card.
339
+ *
340
+ * Native stat dialogs are a two-column `<dl>`; a flex div per row looks the same
341
+ * until the labels differ in width, so this emits the grid's own children.
342
+ *
343
+ * @param label - the row name.
344
+ * @param value - the reading.
345
+ * @param key - React key.
346
+ * @returns the `dt`/`dd` pair.
347
+ */
344
348
  function detailRow(label, value, key) {
345
- return React.createElement('div', {
346
- key,
347
- style: { display: 'flex', justifyContent: 'space-between', gap: '16px', padding: '2px 0' },
348
- },
349
- React.createElement('span', { style: { opacity: 0.75 } }, label),
350
- React.createElement('span', null, value))
349
+ return React.createElement(React.Fragment, { key },
350
+ React.createElement('dt', null, label),
351
+ React.createElement('dd', null, value))
351
352
  }
352
353
 
353
354
  /** The small text under the card that keeps the number honest. */
354
355
  const COST_FOOTNOTE = 'Based on recorded usage and published rates. Your provider\'s bill is authoritative.'
355
356
 
357
+ /**
358
+ * The dock pill's skin, copied from the harness statistics pills
359
+ * (`StatsPills.module.css`) so this one sits in the row instead of looking
360
+ * foreign: same size, same tertiary color, same 14px glyph, never wrapped.
361
+ */
362
+ const PILL_ANCHOR_STYLE = {
363
+ display: 'inline-flex',
364
+ minWidth: 0,
365
+ fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)',
366
+ lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
367
+ }
368
+
369
+ /** The resting pill; hover and open states swap only background and color. */
370
+ const PILL_STYLE = {
371
+ display: 'inline-flex',
372
+ alignItems: 'center',
373
+ gap: '6px',
374
+ boxSizing: 'border-box',
375
+ maxWidth: '100%',
376
+ padding: '1px 8px',
377
+ border: 'none',
378
+ borderRadius: 'var(--dsw-radius-sm, 4px)',
379
+ background: 'transparent',
380
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
381
+ font: 'inherit',
382
+ fontVariantNumeric: 'tabular-nums',
383
+ lineHeight: 'inherit',
384
+ whiteSpace: 'nowrap',
385
+ cursor: 'pointer',
386
+ }
387
+
388
+ /** The label half of the pill, cut with an ellipsis rather than wrapped. */
389
+ const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
390
+
391
+ /** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
392
+ const PANEL_STYLE = {
393
+ position: 'fixed',
394
+ zIndex: 1100,
395
+ boxSizing: 'border-box',
396
+ width: 'max-content',
397
+ minWidth: 'min(300px, calc(100vw - 24px))',
398
+ maxWidth: 'min(440px, calc(100vw - 24px))',
399
+ padding: '16px',
400
+ border: 0,
401
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
402
+ background: 'var(--dsw-specific-menu, Canvas)',
403
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
404
+ boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
405
+ fontSize: '12px',
406
+ lineHeight: '18px',
407
+ color: 'var(--dsw-alias-label-secondary, inherit)',
408
+ cursor: 'default',
409
+ }
410
+
411
+ /** Dialog heading row: section name left, headline value right. */
412
+ const PANEL_TITLE_STYLE = {
413
+ display: 'flex',
414
+ justifyContent: 'space-between',
415
+ gap: '16px',
416
+ marginBottom: '8px',
417
+ color: 'var(--dsw-alias-label-primary, inherit)',
418
+ fontWeight: 500,
419
+ }
420
+
421
+ /** The rule under a section heading. */
422
+ const PANEL_RULE_STYLE = { marginBottom: '10px', borderTop: '0.5px solid var(--dsw-alias-border-l2, currentColor)' }
423
+
424
+ /** The two-column readings grid. */
425
+ const PANEL_DETAILS_STYLE = {
426
+ display: 'grid',
427
+ gridTemplateColumns: 'minmax(76px, auto) minmax(0, 1fr)',
428
+ gap: '6px 16px',
429
+ margin: 0,
430
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
431
+ }
432
+
433
+ /**
434
+ * A 14px coin glyph, the same weight as the harness pill icons. Drawn inline
435
+ * because a plugin face cannot require the icon package without adding it to the
436
+ * frozen module list.
437
+ * @returns the svg element.
438
+ */
439
+ function CoinGlyph() {
440
+ return React.createElement('svg', {
441
+ width: 14,
442
+ height: 14,
443
+ viewBox: '0 0 16 16',
444
+ fill: 'none',
445
+ stroke: 'currentColor',
446
+ strokeWidth: 1.3,
447
+ 'aria-hidden': true,
448
+ style: { flex: 'none' },
449
+ },
450
+ React.createElement('circle', { cx: 8, cy: 8, r: 6.2 }),
451
+ React.createElement('path', { d: 'M9.6 6.1c-.35-.5-1.85-.86-2.6-.3-.9.66-.35 1.5.75 1.72 1.1.22 2.1.5 1.85 1.5-.3 1.2-2.6 1.3-3.4.5' }),
452
+ React.createElement('path', { d: 'M8 4.4v7.2' }))
453
+ }
454
+
356
455
  /**
357
456
  * The composer-dock pill, and the breakdown card it opens.
457
+ *
358
458
  * Reads the priced projection the Host registers, so no rate table or money
359
- * arithmetic runs in the browser.
459
+ * arithmetic runs in the browser. The pill is skinned from the harness's own
460
+ * statistics pill so it joins that row instead of standing out of it.
461
+ *
360
462
  * @param props - standard session props plus our lazy `configForms`.
361
463
  * @returns the pill, or nothing while the feature is off or the session has no usage.
362
464
  */
@@ -364,22 +466,49 @@ window.__ModuleLoader__.load({
364
466
  const switched = useSwitch(props.configForms, 'sessionCost', true)
365
467
  const usage = typeof props.useProjection === 'function' ? props.useProjection('polishCost') : undefined
366
468
  const [open, setOpen] = React.useState(false)
469
+ const [hovered, setHovered] = React.useState(false)
470
+ const [pos, setPos] = React.useState(null)
367
471
  const root = React.useRef(null)
472
+ const panel = React.useRef(null)
473
+
474
+ // Anchored like the native dialogs: fixed in the viewport, clamped to a 12px
475
+ // margin so a pill near the window edge cannot push the card off screen.
476
+ React.useEffect(() => {
477
+ if (!open) {
478
+ setPos(null)
479
+ return undefined
480
+ }
481
+ if (root.current === null || typeof window === 'undefined') return undefined
482
+ const rect = root.current.getBoundingClientRect()
483
+ const width = Math.min(440, window.innerWidth - 24)
484
+ setPos({
485
+ left: Math.max(12, Math.min(rect.left, window.innerWidth - 12 - width)),
486
+ bottom: Math.max(12, window.innerHeight - rect.top + 8),
487
+ })
488
+ return undefined
489
+ }, [open])
368
490
 
369
491
  React.useEffect(() => {
370
492
  if (!open) return undefined
371
- const close = event => {
372
- if (root.current !== null && !root.current.contains(event.target)) setOpen(false)
493
+ const onPointer = event => {
494
+ const insidePill = root.current !== null && root.current.contains(event.target)
495
+ const insidePanel = panel.current !== null && panel.current.contains(event.target)
496
+ if (!insidePill && !insidePanel) setOpen(false)
497
+ }
498
+ const onKey = event => { if (event.key === 'Escape') setOpen(false) }
499
+ document.addEventListener('pointerdown', onPointer)
500
+ document.addEventListener('keydown', onKey)
501
+ return () => {
502
+ document.removeEventListener('pointerdown', onPointer)
503
+ document.removeEventListener('keydown', onKey)
373
504
  }
374
- document.addEventListener('pointerdown', close)
375
- return () => document.removeEventListener('pointerdown', close)
376
505
  }, [open])
377
506
 
378
507
  if (!switched) return null
379
508
  const tokens = typeof usage?.tokens === 'number' ? usage.tokens : 0
380
509
  if (tokens === 0) return null
381
510
  const money = formatMoney(usage.costUsd)
382
- const label = money === '' ? 'Not priced' : (usage.incomplete ? '≈' + money + ' partially priced' : '≈' + money)
511
+ const unpriced = usage.incomplete === true || money === ''
383
512
  const cacheHit = tokens === 0 ? 0 : Math.round((usage.cacheRead ?? 0) / tokens * 100)
384
513
  const models = Array.isArray(usage.models) ? usage.models : []
385
514
 
@@ -401,7 +530,7 @@ window.__ModuleLoader__.load({
401
530
  style: {
402
531
  display: 'flex',
403
532
  height: '6px',
404
- margin: '10px 0 12px',
533
+ margin: '0 0 12px',
405
534
  borderRadius: '999px',
406
535
  overflow: 'hidden',
407
536
  background: 'color-mix(in srgb, CanvasText 10%, transparent)',
@@ -412,49 +541,27 @@ window.__ModuleLoader__.load({
412
541
  segment(usage.cacheWrite, 62, 'bar-cache-write'),
413
542
  segment(usage.output, 82, 'bar-output'))
414
543
 
415
- return React.createElement('div', {
416
- ref: root,
417
- style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
544
+ const reading = money === '' ? 'Not priced' : '≈' + money
545
+ const full = reading + (unpriced ? ' — partially priced: some usage has no published rate' : '')
546
+ const pillStyle = { ...PILL_STYLE, ...(hovered || open
547
+ ? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.12))', color: 'var(--dsw-alias-label-secondary, inherit)' }
548
+ : {}) }
549
+
550
+ const card = React.createElement('div', {
551
+ ref: panel,
552
+ role: 'dialog',
553
+ 'aria-label': 'Estimated session cost',
554
+ 'data-polish-cost-card': '',
555
+ style: { ...PANEL_STYLE, ...(pos === null ? { left: 12, bottom: 12, visibility: 'hidden' } : pos) },
418
556
  },
419
- React.createElement('button', {
420
- type: 'button',
421
- onClick: () => setOpen(!open),
422
- title: 'Estimated session cost',
423
- style: {
424
- background: 'transparent',
425
- border: 'none',
426
- color: 'inherit',
427
- cursor: 'pointer',
428
- font: 'inherit',
429
- padding: '0 2px',
430
- opacity: 0.8,
431
- },
432
- }, label),
433
- open
434
- ? React.createElement('div', {
435
- style: {
436
- position: 'absolute',
437
- bottom: '100%',
438
- left: 0,
439
- marginBottom: '8px',
440
- width: '320px',
441
- padding: '14px',
442
- borderRadius: '10px',
443
- border: '1px solid color-mix(in srgb, CanvasText 16%, transparent)',
444
- background: 'Canvas',
445
- color: 'CanvasText',
446
- boxShadow: '0 10px 30px color-mix(in srgb, CanvasText 18%, transparent)',
447
- fontSize: '12px',
448
- lineHeight: '1.5',
449
- zIndex: 40,
450
- },
451
- },
452
- React.createElement('div', {
453
- style: { display: 'flex', justifyContent: 'space-between', gap: '16px', fontWeight: 500 },
454
- },
455
- React.createElement('span', null, 'Estimated session cost'),
456
- React.createElement('span', null, label)),
457
- bar,
557
+ React.createElement('div', { style: PANEL_TITLE_STYLE },
558
+ React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
559
+ React.createElement(CoinGlyph),
560
+ 'Estimated session cost'),
561
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, money === '' ? 'Not priced' : money)),
562
+ React.createElement('div', { style: PANEL_RULE_STYLE, 'aria-hidden': true }),
563
+ bar,
564
+ React.createElement('dl', { style: PANEL_DETAILS_STYLE },
458
565
  detailRow('Cache hit', cacheHit + '%', 'cache-hit'),
459
566
  detailRow('Uncached input', formatTokens(usage.uncached), 'uncached'),
460
567
  detailRow('Cached input', formatTokens(usage.cacheRead), 'cached'),
@@ -462,13 +569,39 @@ window.__ModuleLoader__.load({
462
569
  detailRow('Output', formatTokens(usage.output), 'output'),
463
570
  detailRow('Model', modelName, 'model'),
464
571
  detailRow('Provider', providerName, 'provider'),
465
- detailRow('Requests', String(usage.requests ?? 0), 'requests'),
466
- React.createElement('div', { style: { borderTop: '1px solid color-mix(in srgb, CanvasText 12%, transparent)', margin: '8px 0' } }),
467
- detailRow('Estimated session cost', money === '' ? 'Not priced' : money, 'cost'),
468
- React.createElement('p', { style: { margin: '10px 0 0', opacity: 0.7, fontSize: '11px' } }, COST_FOOTNOTE))
469
- : null)
572
+ detailRow('Requests', String(usage.requests ?? 0), 'requests')),
573
+ unpriced
574
+ ? React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
575
+ 'Partially priced: some usage in this session has no published rate, so this is a lower bound.')
576
+ : null,
577
+ React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, COST_FOOTNOTE))
578
+
579
+ return React.createElement('span', { ref: root, style: PILL_ANCHOR_STYLE, 'data-composer-stat': 'polish-cost' },
580
+ React.createElement('button', {
581
+ type: 'button',
582
+ className: undefined,
583
+ 'aria-haspopup': 'dialog',
584
+ 'aria-expanded': open,
585
+ 'aria-label': full,
586
+ title: full,
587
+ onClick: () => setOpen(!open),
588
+ onMouseEnter: () => setHovered(true),
589
+ onMouseLeave: () => setHovered(false),
590
+ style: pillStyle,
591
+ },
592
+ React.createElement(CoinGlyph),
593
+ React.createElement('span', { style: PILL_LABEL_STYLE }, reading),
594
+ unpriced ? React.createElement('span', { style: { color: 'var(--dsw-alias-separator-primary, inherit)' } }, '*') : null),
595
+ open && typeof document !== 'undefined' ? ReactDOM.createPortal(card, document.body) : null)
470
596
  }
471
597
 
598
+ /**
599
+ * How long to wait before trying the balance again, in milliseconds. A Shell that
600
+ * has not finished signing in answers the first read with nothing, and a line that
601
+ * gives up after one attempt would stay empty for the life of the page.
602
+ */
603
+ const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
604
+
472
605
  /**
473
606
  * The remaining-balance line in the sidebar foot.
474
607
  *
@@ -486,15 +619,43 @@ window.__ModuleLoader__.load({
486
619
  React.useEffect(() => {
487
620
  if (!enabled || typeof read !== 'function') return undefined
488
621
  let live = true
489
- Promise.resolve()
490
- .then(() => read())
491
- .then(view => {
492
- if (live) setWallet(walletOf(view))
493
- }, () => {
494
- if (live) setWallet(undefined)
495
- })
622
+ let attempt = 0
623
+ let timer
624
+ const look = () => {
625
+ Promise.resolve()
626
+ .then(() => read())
627
+ .then(view => {
628
+ if (!live) return
629
+ const next = walletOf(view)
630
+ if (next !== undefined) {
631
+ setWallet(next)
632
+ return
633
+ }
634
+ if (attempt < BALANCE_RETRY_DELAYS.length) {
635
+ timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
636
+ attempt += 1
637
+ }
638
+ }, () => {
639
+ if (!live) return
640
+ if (attempt < BALANCE_RETRY_DELAYS.length) {
641
+ timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
642
+ attempt += 1
643
+ }
644
+ })
645
+ }
646
+ look()
647
+ // A window that was hidden through sign-in comes back with an answer the
648
+ // first attempt could not have had; retry from the start when it regains focus.
649
+ const onFocus = () => {
650
+ attempt = 0
651
+ clearTimeout(timer)
652
+ look()
653
+ }
654
+ if (typeof window !== 'undefined') window.addEventListener('focus', onFocus)
496
655
  return () => {
497
656
  live = false
657
+ clearTimeout(timer)
658
+ if (typeof window !== 'undefined') window.removeEventListener('focus', onFocus)
498
659
  }
499
660
  }, [enabled, read])
500
661
  if (!enabled) return null
@@ -548,13 +709,17 @@ window.__ModuleLoader__.load({
548
709
  order: 2,
549
710
  inject: () => ({ configForms }),
550
711
  }, CostPill))
551
- const loadBalance = makeBalanceReader(ctx, CLIENT_VERSION)
552
- ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
553
- name: 'sidebar.footer.action',
554
- id: 'polish-balance',
555
- order: 3,
556
- inject: () => ({ configForms, loadBalance }),
557
- }, BalanceLine))
712
+ // The account Remote namespace is optional: a Shell with no account still gets
713
+ // the Configure page and the cost pill, and only loses the balance line.
714
+ ctx.inject(['remote', 'remote.account'], (accountCtx) => {
715
+ const loadBalance = () => readBalance(accountCtx, CLIENT_VERSION)
716
+ accountCtx.slots.inject('sidebar.footer.action', () => accountCtx.slots.register({
717
+ name: 'sidebar.footer.action',
718
+ id: 'polish-balance',
719
+ order: 3,
720
+ inject: () => ({ configForms, loadBalance }),
721
+ }, BalanceLine))
722
+ })
558
723
  },
559
724
  }
560
725
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
5
5
  "keywords": [
6
6
  "dsh",