@aryacode/dsh-aryacode-polish 0.3.7 → 0.4.4

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/lib/client.js CHANGED
@@ -19,11 +19,17 @@ window.__ModuleLoader__.load({
19
19
  "label": "Account balance",
20
20
  "description": "Show the remaining account balance in the sidebar foot, above the account row.",
21
21
  "defaultOn": true
22
+ },
23
+ {
24
+ "key": "openWorkspaceFolder",
25
+ "label": "Open workspace folder",
26
+ "description": "Add an “Open workspace folder” row to a Session’s menu that opens its workspace in the file browser.",
27
+ "defaultOn": true
22
28
  }
23
29
  ]
24
30
  const ROW_ID = "polish"
25
31
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
26
- const CLIENT_VERSION = "0.3.7"
32
+ const CLIENT_VERSION = "0.4.4"
27
33
 
28
34
  /**
29
35
  * The two pure decisions behind the balance line: which wallet to show, and how
@@ -60,12 +66,19 @@ window.__ModuleLoader__.load({
60
66
  /**
61
67
  * Reading the account balance from a plugin face.
62
68
  *
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.
69
+ * The account Remote methods are the same ones the shipped account row uses. An
70
+ * earlier version waited for the host account bundle's own access grant before
71
+ * reading, on the theory that the grant proves the call is allowed. It does not:
72
+ * that grant belongs to the bundle that owns the account stream, and a plugin
73
+ * that waits for it can wait forever — the sidebar line then stays empty with no
74
+ * error anywhere, which is exactly what a user reports as "I don't see the
75
+ * balance".
76
+ *
77
+ * So this reads directly. The Host still decides: an unauthorised call comes back
78
+ * `{ ok: false }` (or throws) and this resolves to `undefined`. Every failure — no
79
+ * account namespace in this Shell, a refused call, a failed call — resolves to
80
+ * `undefined` instead of throwing, because a sidebar line is never worth breaking
81
+ * the shell over.
69
82
  *
70
83
  * This module is inlined into `lib/client.js` by `scripts/build-client.mjs` and
71
84
  * imported directly by `test/account.spec.mjs`, so the tested source is the one
@@ -94,63 +107,49 @@ window.__ModuleLoader__.load({
94
107
  }
95
108
 
96
109
  /**
97
- * Build the one-shot balance reader.
110
+ * The account Remote namespace, or undefined when this Shell has none.
98
111
  *
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.
112
+ * @param ctx - the browser plugin context; the balance surface is registered
113
+ * inside `ctx.inject(['remote', 'remote.account'], …)` so this normally resolves.
114
+ * @returns the namespace, or undefined.
102
115
  */
103
- function makeBalanceReader(ctx, version, { timeoutMs = 10000 } = {}) {
104
- const service = name => {
105
- try {
106
- return ctx.get(name)
107
- } catch {
108
- return undefined
109
- }
116
+ function accountService(ctx) {
117
+ try {
118
+ const remote = ctx.remote !== undefined ? ctx.remote : ctx.get('remote')
119
+ const account = remote !== undefined && remote !== null ? remote.account : undefined
120
+ return account !== undefined && account !== null && typeof account.getBalance === 'function' ? account : undefined
121
+ } catch {
122
+ return undefined
110
123
  }
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
- })
124
+ }
125
+
126
+ /**
127
+ * Read the balance once.
128
+ *
129
+ * @param ctx - the browser plugin context.
130
+ * @param version - the version this build stamps into the account metadata.
131
+ * @param options - `timeoutMs` bounds a call that never settles.
132
+ * @returns the ready balance view, or undefined when there is no number to show.
133
+ */
134
+ async function readBalance(ctx, version, { timeoutMs = 15000 } = {}) {
135
+ const account = accountService(ctx)
136
+ if (account === undefined) return undefined
137
+ const call = (async () => {
138
+ const result = await account.getBalance(clientMetadata(version))
139
+ if (result === undefined || result === null || result.ok !== true) return undefined
140
+ const value = result.value
141
+ return value !== undefined && value !== null && value.status === 'ready' ? value : undefined
142
+ })().catch(() => undefined)
143
+ let timer
144
+ const timeout = new Promise(resolve => {
145
+ timer = setTimeout(() => resolve(undefined), timeoutMs)
146
+ // A pending balance read must not keep a Node process alive during tests.
147
+ if (typeof timer === 'object' && timer !== null && typeof timer.unref === 'function') timer.unref()
148
+ })
149
+ try {
150
+ return await Promise.race([call, timeout])
151
+ } finally {
152
+ clearTimeout(timer)
154
153
  }
155
154
  }
156
155
 
@@ -171,6 +170,20 @@ window.__ModuleLoader__.load({
171
170
  */
172
171
 
173
172
  const React = require('react')
173
+ const ReactDOM = require('react-dom')
174
+
175
+ /**
176
+ * The Shell's own UI primitives. Guarded because the menu row degrades to a plain
177
+ * button when a Shell answers no primitives, and a bundle that throws on load
178
+ * would take every other surface down with it.
179
+ */
180
+ const primitives = (() => {
181
+ try {
182
+ return require('@deepseek-ai/dsh-client-ui-primitives')
183
+ } catch {
184
+ return undefined
185
+ }
186
+ })()
174
187
 
175
188
  /** Shared spacing for the page's prose. */
176
189
  const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
@@ -340,23 +353,131 @@ window.__ModuleLoader__.load({
340
353
  return Number.isFinite(count) ? new Intl.NumberFormat('en-US').format(count) + ' tok' : ''
341
354
  }
342
355
 
343
- /** One label/value line of the breakdown card. */
356
+ /**
357
+ * One label/value line of the breakdown card.
358
+ *
359
+ * Native stat dialogs are a two-column `<dl>`; a flex div per row looks the same
360
+ * until the labels differ in width, so this emits the grid's own children.
361
+ *
362
+ * @param label - the row name.
363
+ * @param value - the reading.
364
+ * @param key - React key.
365
+ * @returns the `dt`/`dd` pair.
366
+ */
344
367
  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))
368
+ return React.createElement(React.Fragment, { key },
369
+ React.createElement('dt', null, label),
370
+ React.createElement('dd', null, value))
351
371
  }
352
372
 
353
373
  /** The small text under the card that keeps the number honest. */
354
374
  const COST_FOOTNOTE = 'Based on recorded usage and published rates. Your provider\'s bill is authoritative.'
355
375
 
376
+ /**
377
+ * The dock pill's skin, copied from the harness statistics pills
378
+ * (`StatsPills.module.css`) so this one sits in the row instead of looking
379
+ * foreign: same size, same tertiary color, same 14px glyph, never wrapped.
380
+ */
381
+ const PILL_ANCHOR_STYLE = {
382
+ display: 'inline-flex',
383
+ minWidth: 0,
384
+ fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)',
385
+ lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
386
+ }
387
+
388
+ /** The resting pill; hover and open states swap only background and color. */
389
+ const PILL_STYLE = {
390
+ display: 'inline-flex',
391
+ alignItems: 'center',
392
+ gap: '6px',
393
+ boxSizing: 'border-box',
394
+ maxWidth: '100%',
395
+ padding: '1px 8px',
396
+ border: 'none',
397
+ borderRadius: 'var(--dsw-radius-sm, 4px)',
398
+ background: 'transparent',
399
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
400
+ font: 'inherit',
401
+ fontVariantNumeric: 'tabular-nums',
402
+ lineHeight: 'inherit',
403
+ whiteSpace: 'nowrap',
404
+ cursor: 'pointer',
405
+ }
406
+
407
+ /** The label half of the pill, cut with an ellipsis rather than wrapped. */
408
+ const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
409
+
410
+ /** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
411
+ const PANEL_STYLE = {
412
+ position: 'fixed',
413
+ zIndex: 1100,
414
+ boxSizing: 'border-box',
415
+ width: 'max-content',
416
+ minWidth: 'min(300px, calc(100vw - 24px))',
417
+ maxWidth: 'min(440px, calc(100vw - 24px))',
418
+ padding: '16px',
419
+ border: 0,
420
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
421
+ background: 'var(--dsw-specific-menu, Canvas)',
422
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
423
+ boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
424
+ fontSize: '12px',
425
+ lineHeight: '18px',
426
+ color: 'var(--dsw-alias-label-secondary, inherit)',
427
+ cursor: 'default',
428
+ }
429
+
430
+ /** Dialog heading row: section name left, headline value right. */
431
+ const PANEL_TITLE_STYLE = {
432
+ display: 'flex',
433
+ justifyContent: 'space-between',
434
+ gap: '16px',
435
+ marginBottom: '8px',
436
+ color: 'var(--dsw-alias-label-primary, inherit)',
437
+ fontWeight: 500,
438
+ }
439
+
440
+ /** The rule under a section heading. */
441
+ const PANEL_RULE_STYLE = { marginBottom: '10px', borderTop: '0.5px solid var(--dsw-alias-border-l2, currentColor)' }
442
+
443
+ /** The two-column readings grid. */
444
+ const PANEL_DETAILS_STYLE = {
445
+ display: 'grid',
446
+ gridTemplateColumns: 'minmax(76px, auto) minmax(0, 1fr)',
447
+ gap: '6px 16px',
448
+ margin: 0,
449
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
450
+ }
451
+
452
+ /**
453
+ * A 14px coin glyph, the same weight as the harness pill icons. Drawn inline
454
+ * because a plugin face cannot require the icon package without adding it to the
455
+ * frozen module list.
456
+ * @returns the svg element.
457
+ */
458
+ function CoinGlyph() {
459
+ return React.createElement('svg', {
460
+ width: 14,
461
+ height: 14,
462
+ viewBox: '0 0 16 16',
463
+ fill: 'none',
464
+ stroke: 'currentColor',
465
+ strokeWidth: 1.3,
466
+ 'aria-hidden': true,
467
+ style: { flex: 'none' },
468
+ },
469
+ React.createElement('circle', { cx: 8, cy: 8, r: 6.2 }),
470
+ 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' }),
471
+ React.createElement('path', { d: 'M8 4.4v7.2' }))
472
+ }
473
+
356
474
  /**
357
475
  * The composer-dock pill, and the breakdown card it opens.
476
+ *
358
477
  * Reads the priced projection the Host registers, so no rate table or money
359
- * arithmetic runs in the browser.
478
+ * arithmetic runs in the browser. The pill is skinned from the harness's own
479
+ * statistics pill so it joins that row instead of standing out of it.
480
+ *
360
481
  * @param props - standard session props plus our lazy `configForms`.
361
482
  * @returns the pill, or nothing while the feature is off or the session has no usage.
362
483
  */
@@ -364,22 +485,49 @@ window.__ModuleLoader__.load({
364
485
  const switched = useSwitch(props.configForms, 'sessionCost', true)
365
486
  const usage = typeof props.useProjection === 'function' ? props.useProjection('polishCost') : undefined
366
487
  const [open, setOpen] = React.useState(false)
488
+ const [hovered, setHovered] = React.useState(false)
489
+ const [pos, setPos] = React.useState(null)
367
490
  const root = React.useRef(null)
491
+ const panel = React.useRef(null)
492
+
493
+ // Anchored like the native dialogs: fixed in the viewport, clamped to a 12px
494
+ // margin so a pill near the window edge cannot push the card off screen.
495
+ React.useEffect(() => {
496
+ if (!open) {
497
+ setPos(null)
498
+ return undefined
499
+ }
500
+ if (root.current === null || typeof window === 'undefined') return undefined
501
+ const rect = root.current.getBoundingClientRect()
502
+ const width = Math.min(440, window.innerWidth - 24)
503
+ setPos({
504
+ left: Math.max(12, Math.min(rect.left, window.innerWidth - 12 - width)),
505
+ bottom: Math.max(12, window.innerHeight - rect.top + 8),
506
+ })
507
+ return undefined
508
+ }, [open])
368
509
 
369
510
  React.useEffect(() => {
370
511
  if (!open) return undefined
371
- const close = event => {
372
- if (root.current !== null && !root.current.contains(event.target)) setOpen(false)
512
+ const onPointer = event => {
513
+ const insidePill = root.current !== null && root.current.contains(event.target)
514
+ const insidePanel = panel.current !== null && panel.current.contains(event.target)
515
+ if (!insidePill && !insidePanel) setOpen(false)
516
+ }
517
+ const onKey = event => { if (event.key === 'Escape') setOpen(false) }
518
+ document.addEventListener('pointerdown', onPointer)
519
+ document.addEventListener('keydown', onKey)
520
+ return () => {
521
+ document.removeEventListener('pointerdown', onPointer)
522
+ document.removeEventListener('keydown', onKey)
373
523
  }
374
- document.addEventListener('pointerdown', close)
375
- return () => document.removeEventListener('pointerdown', close)
376
524
  }, [open])
377
525
 
378
526
  if (!switched) return null
379
527
  const tokens = typeof usage?.tokens === 'number' ? usage.tokens : 0
380
528
  if (tokens === 0) return null
381
529
  const money = formatMoney(usage.costUsd)
382
- const label = money === '' ? 'Not priced' : (usage.incomplete ? '≈' + money + ' partially priced' : '≈' + money)
530
+ const unpriced = usage.incomplete === true || money === ''
383
531
  const cacheHit = tokens === 0 ? 0 : Math.round((usage.cacheRead ?? 0) / tokens * 100)
384
532
  const models = Array.isArray(usage.models) ? usage.models : []
385
533
 
@@ -401,7 +549,7 @@ window.__ModuleLoader__.load({
401
549
  style: {
402
550
  display: 'flex',
403
551
  height: '6px',
404
- margin: '10px 0 12px',
552
+ margin: '0 0 12px',
405
553
  borderRadius: '999px',
406
554
  overflow: 'hidden',
407
555
  background: 'color-mix(in srgb, CanvasText 10%, transparent)',
@@ -412,49 +560,27 @@ window.__ModuleLoader__.load({
412
560
  segment(usage.cacheWrite, 62, 'bar-cache-write'),
413
561
  segment(usage.output, 82, 'bar-output'))
414
562
 
415
- return React.createElement('div', {
416
- ref: root,
417
- style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
563
+ const reading = money === '' ? 'Not priced' : '≈' + money
564
+ const full = reading + (unpriced ? ' — partially priced: some usage has no published rate' : '')
565
+ const pillStyle = { ...PILL_STYLE, ...(hovered || open
566
+ ? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.12))', color: 'var(--dsw-alias-label-secondary, inherit)' }
567
+ : {}) }
568
+
569
+ const card = React.createElement('div', {
570
+ ref: panel,
571
+ role: 'dialog',
572
+ 'aria-label': 'Estimated session cost',
573
+ 'data-polish-cost-card': '',
574
+ style: { ...PANEL_STYLE, ...(pos === null ? { left: 12, bottom: 12, visibility: 'hidden' } : pos) },
418
575
  },
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,
576
+ React.createElement('div', { style: PANEL_TITLE_STYLE },
577
+ React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
578
+ React.createElement(CoinGlyph),
579
+ 'Estimated session cost'),
580
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, money === '' ? 'Not priced' : money)),
581
+ React.createElement('div', { style: PANEL_RULE_STYLE, 'aria-hidden': true }),
582
+ bar,
583
+ React.createElement('dl', { style: PANEL_DETAILS_STYLE },
458
584
  detailRow('Cache hit', cacheHit + '%', 'cache-hit'),
459
585
  detailRow('Uncached input', formatTokens(usage.uncached), 'uncached'),
460
586
  detailRow('Cached input', formatTokens(usage.cacheRead), 'cached'),
@@ -462,13 +588,39 @@ window.__ModuleLoader__.load({
462
588
  detailRow('Output', formatTokens(usage.output), 'output'),
463
589
  detailRow('Model', modelName, 'model'),
464
590
  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)
591
+ detailRow('Requests', String(usage.requests ?? 0), 'requests')),
592
+ unpriced
593
+ ? React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
594
+ 'Partially priced: some usage in this session has no published rate, so this is a lower bound.')
595
+ : null,
596
+ React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, COST_FOOTNOTE))
597
+
598
+ return React.createElement('span', { ref: root, style: PILL_ANCHOR_STYLE, 'data-composer-stat': 'polish-cost' },
599
+ React.createElement('button', {
600
+ type: 'button',
601
+ className: undefined,
602
+ 'aria-haspopup': 'dialog',
603
+ 'aria-expanded': open,
604
+ 'aria-label': full,
605
+ title: full,
606
+ onClick: () => setOpen(!open),
607
+ onMouseEnter: () => setHovered(true),
608
+ onMouseLeave: () => setHovered(false),
609
+ style: pillStyle,
610
+ },
611
+ React.createElement(CoinGlyph),
612
+ React.createElement('span', { style: PILL_LABEL_STYLE }, reading),
613
+ unpriced ? React.createElement('span', { style: { color: 'var(--dsw-alias-separator-primary, inherit)' } }, '*') : null),
614
+ open && typeof document !== 'undefined' ? ReactDOM.createPortal(card, document.body) : null)
470
615
  }
471
616
 
617
+ /**
618
+ * How long to wait before trying the balance again, in milliseconds. A Shell that
619
+ * has not finished signing in answers the first read with nothing, and a line that
620
+ * gives up after one attempt would stay empty for the life of the page.
621
+ */
622
+ const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
623
+
472
624
  /**
473
625
  * The remaining-balance line in the sidebar foot.
474
626
  *
@@ -486,15 +638,43 @@ window.__ModuleLoader__.load({
486
638
  React.useEffect(() => {
487
639
  if (!enabled || typeof read !== 'function') return undefined
488
640
  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
- })
641
+ let attempt = 0
642
+ let timer
643
+ const look = () => {
644
+ Promise.resolve()
645
+ .then(() => read())
646
+ .then(view => {
647
+ if (!live) return
648
+ const next = walletOf(view)
649
+ if (next !== undefined) {
650
+ setWallet(next)
651
+ return
652
+ }
653
+ if (attempt < BALANCE_RETRY_DELAYS.length) {
654
+ timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
655
+ attempt += 1
656
+ }
657
+ }, () => {
658
+ if (!live) return
659
+ if (attempt < BALANCE_RETRY_DELAYS.length) {
660
+ timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
661
+ attempt += 1
662
+ }
663
+ })
664
+ }
665
+ look()
666
+ // A window that was hidden through sign-in comes back with an answer the
667
+ // first attempt could not have had; retry from the start when it regains focus.
668
+ const onFocus = () => {
669
+ attempt = 0
670
+ clearTimeout(timer)
671
+ look()
672
+ }
673
+ if (typeof window !== 'undefined') window.addEventListener('focus', onFocus)
496
674
  return () => {
497
675
  live = false
676
+ clearTimeout(timer)
677
+ if (typeof window !== 'undefined') window.removeEventListener('focus', onFocus)
498
678
  }
499
679
  }, [enabled, read])
500
680
  if (!enabled) return null
@@ -523,6 +703,74 @@ window.__ModuleLoader__.load({
523
703
  }, props.wide === false ? money : 'Balance ' + money)
524
704
  }
525
705
 
706
+ /**
707
+ * The workspace a Session belongs to.
708
+ *
709
+ * Occupants of `sidebar.workspaces.session.menu.item` address their data by
710
+ * Session id and must not bind or retain the Session, so the workspace is read
711
+ * from the Workspace snapshot at render time and never memoized.
712
+ *
713
+ * @param workspaces - the Shell's Workspace service, when it is loaded.
714
+ * @param sessionId - the row's Session.
715
+ * @returns the owning workspace, or undefined when it cannot be resolved.
716
+ */
717
+ function workspaceOf(workspaces, sessionId) {
718
+ if (workspaces === undefined || workspaces === null || sessionId === undefined) return undefined
719
+ let items
720
+ try {
721
+ items = workspaces.list.getSnapshot().items
722
+ } catch {
723
+ return undefined
724
+ }
725
+ if (!Array.isArray(items)) return undefined
726
+ return items.find(item => Array.isArray(item.sessionIds)
727
+ && item.sessionIds.indexOf(sessionId) !== -1
728
+ && typeof item.path === 'string'
729
+ && item.path !== '')
730
+ }
731
+
732
+ /**
733
+ * "Open workspace folder": one row in a Session's "…" menu.
734
+ *
735
+ * It renders nothing when the switch is off or the Session has no workspace the
736
+ * Host could open, because a menu row that cannot act is worse than no row.
737
+ *
738
+ * @param props - the row's Session id, our lazy `configForms`/`workspaces`, the
739
+ * opener, and the slot's injected `useMenuOpenState` hook.
740
+ * @returns the menu row, or null.
741
+ */
742
+ function OpenFolderMenuItem(props) {
743
+ const enabled = useSwitch(props.configForms, 'openWorkspaceFolder', true)
744
+ // Hooks run unconditionally; the row simply renders nothing when it cannot act.
745
+ const menu = typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState() : undefined
746
+ const closeMenu = Array.isArray(menu) && typeof menu[1] === 'function' ? menu[1] : undefined
747
+ const workspace = enabled ? workspaceOf(props.workspaces, props.sessionId) : undefined
748
+ if (!enabled || workspace === undefined) return null
749
+ const label = 'Open workspace folder'
750
+ const onSelect = () => {
751
+ if (closeMenu !== undefined) closeMenu(false)
752
+ const open = props.openFolder
753
+ if (typeof open !== 'function') return
754
+ void Promise.resolve()
755
+ .then(() => open(workspace))
756
+ .catch(error => {
757
+ // A menu row that silently does nothing is unfixable in the field, so the
758
+ // failure is reported instead of dropped. This is the only diagnostic the
759
+ // bundle writes.
760
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
761
+ console.warn('[aryacode-polish] open workspace folder failed:', error && error.message ? error.message : error)
762
+ }
763
+ })
764
+ }
765
+ if (primitives !== undefined && typeof primitives.MenuItemButton === 'function') {
766
+ const icon = typeof primitives.IconFolderOpenRegular === 'function'
767
+ ? React.createElement(primitives.IconFolderOpenRegular)
768
+ : undefined
769
+ return React.createElement(primitives.MenuItemButton, { icon, onSelect }, label)
770
+ }
771
+ return React.createElement('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label)
772
+ }
773
+
526
774
  return {
527
775
  inject: ['slots'],
528
776
  /**
@@ -548,13 +796,43 @@ window.__ModuleLoader__.load({
548
796
  order: 2,
549
797
  inject: () => ({ configForms }),
550
798
  }, 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))
799
+ // The account Remote namespace is optional: a Shell with no account still gets
800
+ // the Configure page and the cost pill, and only loses the balance line.
801
+ ctx.inject(['remote', 'remote.account'], (accountCtx) => {
802
+ const loadBalance = () => readBalance(accountCtx, CLIENT_VERSION)
803
+ accountCtx.slots.inject('sidebar.footer.action', () => accountCtx.slots.register({
804
+ name: 'sidebar.footer.action',
805
+ id: 'polish-balance',
806
+ order: 3,
807
+ inject: () => ({ configForms, loadBalance }),
808
+ }, BalanceLine))
809
+ })
810
+ // The Workspace service and the Session Remote are both required for the menu
811
+ // row: it reads the folder from the first and opens it through the second.
812
+ // They are declared on the inject list because a Remote namespace may not be
813
+ // read through `ctx.get` — the Shell's guard refuses it.
814
+ ctx.inject(['workspaces', 'remote', 'remote.session'], (workspaceCtx) => {
815
+ const openFolder = async (workspace) => {
816
+ const remote = workspaceCtx.remote.session
817
+ // A Remote method answers an envelope (`{ ok, value }`), not the bare
818
+ // return value; a Shell that answered a plain boolean is tolerated too.
819
+ const answer = await remote.canOpenWorkspacePath()
820
+ const available = answer !== null && typeof answer === 'object'
821
+ ? answer.ok === true && answer.value === true
822
+ : answer === true
823
+ if (!available) throw new Error('this Host cannot open a folder')
824
+ const result = await remote.openWorkspacePath({ path: workspace.path })
825
+ if (result !== null && typeof result === 'object' && 'ok' in result && result.ok !== true) {
826
+ throw new Error('the Host refused to open the folder')
827
+ }
828
+ }
829
+ workspaceCtx.slots.inject('sidebar.workspaces.session.menu.item', () => workspaceCtx.slots.register({
830
+ name: 'sidebar.workspaces.session.menu.item',
831
+ id: 'polish-open-folder',
832
+ order: 460,
833
+ inject: () => ({ configForms, openFolder, workspaces: workspaceCtx.get('workspaces') }),
834
+ }, OpenFolderMenuItem))
835
+ })
558
836
  },
559
837
  }
560
838
  },