@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/CHANGELOG.md +89 -0
- package/README.md +3 -2
- package/client/account.js +53 -60
- package/client/face.js +354 -75
- package/client/switches.json +6 -0
- package/docs/workspace-folder.md +67 -0
- package/entries/registry.js +6 -0
- package/lib/client.js +414 -136
- package/package.json +1 -1
package/client/face.js
CHANGED
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
const React = require('react')
|
|
18
|
+
const ReactDOM = require('react-dom')
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The Shell's own UI primitives. Guarded because the menu row degrades to a plain
|
|
22
|
+
* button when a Shell answers no primitives, and a bundle that throws on load
|
|
23
|
+
* would take every other surface down with it.
|
|
24
|
+
*/
|
|
25
|
+
const primitives = (() => {
|
|
26
|
+
try {
|
|
27
|
+
return require('@deepseek-ai/dsh-client-ui-primitives')
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
})()
|
|
18
32
|
|
|
19
33
|
/** Shared spacing for the page's prose. */
|
|
20
34
|
const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
|
|
@@ -184,23 +198,131 @@ function formatTokens(value) {
|
|
|
184
198
|
return Number.isFinite(count) ? new Intl.NumberFormat('en-US').format(count) + ' tok' : ''
|
|
185
199
|
}
|
|
186
200
|
|
|
187
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* One label/value line of the breakdown card.
|
|
203
|
+
*
|
|
204
|
+
* Native stat dialogs are a two-column `<dl>`; a flex div per row looks the same
|
|
205
|
+
* until the labels differ in width, so this emits the grid's own children.
|
|
206
|
+
*
|
|
207
|
+
* @param label - the row name.
|
|
208
|
+
* @param value - the reading.
|
|
209
|
+
* @param key - React key.
|
|
210
|
+
* @returns the `dt`/`dd` pair.
|
|
211
|
+
*/
|
|
188
212
|
function detailRow(label, value, key) {
|
|
189
|
-
return React.createElement(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
},
|
|
193
|
-
React.createElement('span', { style: { opacity: 0.75 } }, label),
|
|
194
|
-
React.createElement('span', null, value))
|
|
213
|
+
return React.createElement(React.Fragment, { key },
|
|
214
|
+
React.createElement('dt', null, label),
|
|
215
|
+
React.createElement('dd', null, value))
|
|
195
216
|
}
|
|
196
217
|
|
|
197
218
|
/** The small text under the card that keeps the number honest. */
|
|
198
219
|
const COST_FOOTNOTE = 'Based on recorded usage and published rates. Your provider\'s bill is authoritative.'
|
|
199
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The dock pill's skin, copied from the harness statistics pills
|
|
223
|
+
* (`StatsPills.module.css`) so this one sits in the row instead of looking
|
|
224
|
+
* foreign: same size, same tertiary color, same 14px glyph, never wrapped.
|
|
225
|
+
*/
|
|
226
|
+
const PILL_ANCHOR_STYLE = {
|
|
227
|
+
display: 'inline-flex',
|
|
228
|
+
minWidth: 0,
|
|
229
|
+
fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)',
|
|
230
|
+
lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The resting pill; hover and open states swap only background and color. */
|
|
234
|
+
const PILL_STYLE = {
|
|
235
|
+
display: 'inline-flex',
|
|
236
|
+
alignItems: 'center',
|
|
237
|
+
gap: '6px',
|
|
238
|
+
boxSizing: 'border-box',
|
|
239
|
+
maxWidth: '100%',
|
|
240
|
+
padding: '1px 8px',
|
|
241
|
+
border: 'none',
|
|
242
|
+
borderRadius: 'var(--dsw-radius-sm, 4px)',
|
|
243
|
+
background: 'transparent',
|
|
244
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
245
|
+
font: 'inherit',
|
|
246
|
+
fontVariantNumeric: 'tabular-nums',
|
|
247
|
+
lineHeight: 'inherit',
|
|
248
|
+
whiteSpace: 'nowrap',
|
|
249
|
+
cursor: 'pointer',
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The label half of the pill, cut with an ellipsis rather than wrapped. */
|
|
253
|
+
const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
|
|
254
|
+
|
|
255
|
+
/** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
|
|
256
|
+
const PANEL_STYLE = {
|
|
257
|
+
position: 'fixed',
|
|
258
|
+
zIndex: 1100,
|
|
259
|
+
boxSizing: 'border-box',
|
|
260
|
+
width: 'max-content',
|
|
261
|
+
minWidth: 'min(300px, calc(100vw - 24px))',
|
|
262
|
+
maxWidth: 'min(440px, calc(100vw - 24px))',
|
|
263
|
+
padding: '16px',
|
|
264
|
+
border: 0,
|
|
265
|
+
borderRadius: 'var(--dsw-radius-lg, 12px)',
|
|
266
|
+
background: 'var(--dsw-specific-menu, Canvas)',
|
|
267
|
+
backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
|
|
268
|
+
boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
|
|
269
|
+
fontSize: '12px',
|
|
270
|
+
lineHeight: '18px',
|
|
271
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
272
|
+
cursor: 'default',
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Dialog heading row: section name left, headline value right. */
|
|
276
|
+
const PANEL_TITLE_STYLE = {
|
|
277
|
+
display: 'flex',
|
|
278
|
+
justifyContent: 'space-between',
|
|
279
|
+
gap: '16px',
|
|
280
|
+
marginBottom: '8px',
|
|
281
|
+
color: 'var(--dsw-alias-label-primary, inherit)',
|
|
282
|
+
fontWeight: 500,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The rule under a section heading. */
|
|
286
|
+
const PANEL_RULE_STYLE = { marginBottom: '10px', borderTop: '0.5px solid var(--dsw-alias-border-l2, currentColor)' }
|
|
287
|
+
|
|
288
|
+
/** The two-column readings grid. */
|
|
289
|
+
const PANEL_DETAILS_STYLE = {
|
|
290
|
+
display: 'grid',
|
|
291
|
+
gridTemplateColumns: 'minmax(76px, auto) minmax(0, 1fr)',
|
|
292
|
+
gap: '6px 16px',
|
|
293
|
+
margin: 0,
|
|
294
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A 14px coin glyph, the same weight as the harness pill icons. Drawn inline
|
|
299
|
+
* because a plugin face cannot require the icon package without adding it to the
|
|
300
|
+
* frozen module list.
|
|
301
|
+
* @returns the svg element.
|
|
302
|
+
*/
|
|
303
|
+
function CoinGlyph() {
|
|
304
|
+
return React.createElement('svg', {
|
|
305
|
+
width: 14,
|
|
306
|
+
height: 14,
|
|
307
|
+
viewBox: '0 0 16 16',
|
|
308
|
+
fill: 'none',
|
|
309
|
+
stroke: 'currentColor',
|
|
310
|
+
strokeWidth: 1.3,
|
|
311
|
+
'aria-hidden': true,
|
|
312
|
+
style: { flex: 'none' },
|
|
313
|
+
},
|
|
314
|
+
React.createElement('circle', { cx: 8, cy: 8, r: 6.2 }),
|
|
315
|
+
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' }),
|
|
316
|
+
React.createElement('path', { d: 'M8 4.4v7.2' }))
|
|
317
|
+
}
|
|
318
|
+
|
|
200
319
|
/**
|
|
201
320
|
* The composer-dock pill, and the breakdown card it opens.
|
|
321
|
+
*
|
|
202
322
|
* Reads the priced projection the Host registers, so no rate table or money
|
|
203
|
-
* arithmetic runs in the browser.
|
|
323
|
+
* arithmetic runs in the browser. The pill is skinned from the harness's own
|
|
324
|
+
* statistics pill so it joins that row instead of standing out of it.
|
|
325
|
+
*
|
|
204
326
|
* @param props - standard session props plus our lazy `configForms`.
|
|
205
327
|
* @returns the pill, or nothing while the feature is off or the session has no usage.
|
|
206
328
|
*/
|
|
@@ -208,22 +330,49 @@ function CostPill(props) {
|
|
|
208
330
|
const switched = useSwitch(props.configForms, 'sessionCost', true)
|
|
209
331
|
const usage = typeof props.useProjection === 'function' ? props.useProjection('polishCost') : undefined
|
|
210
332
|
const [open, setOpen] = React.useState(false)
|
|
333
|
+
const [hovered, setHovered] = React.useState(false)
|
|
334
|
+
const [pos, setPos] = React.useState(null)
|
|
211
335
|
const root = React.useRef(null)
|
|
336
|
+
const panel = React.useRef(null)
|
|
337
|
+
|
|
338
|
+
// Anchored like the native dialogs: fixed in the viewport, clamped to a 12px
|
|
339
|
+
// margin so a pill near the window edge cannot push the card off screen.
|
|
340
|
+
React.useEffect(() => {
|
|
341
|
+
if (!open) {
|
|
342
|
+
setPos(null)
|
|
343
|
+
return undefined
|
|
344
|
+
}
|
|
345
|
+
if (root.current === null || typeof window === 'undefined') return undefined
|
|
346
|
+
const rect = root.current.getBoundingClientRect()
|
|
347
|
+
const width = Math.min(440, window.innerWidth - 24)
|
|
348
|
+
setPos({
|
|
349
|
+
left: Math.max(12, Math.min(rect.left, window.innerWidth - 12 - width)),
|
|
350
|
+
bottom: Math.max(12, window.innerHeight - rect.top + 8),
|
|
351
|
+
})
|
|
352
|
+
return undefined
|
|
353
|
+
}, [open])
|
|
212
354
|
|
|
213
355
|
React.useEffect(() => {
|
|
214
356
|
if (!open) return undefined
|
|
215
|
-
const
|
|
216
|
-
|
|
357
|
+
const onPointer = event => {
|
|
358
|
+
const insidePill = root.current !== null && root.current.contains(event.target)
|
|
359
|
+
const insidePanel = panel.current !== null && panel.current.contains(event.target)
|
|
360
|
+
if (!insidePill && !insidePanel) setOpen(false)
|
|
361
|
+
}
|
|
362
|
+
const onKey = event => { if (event.key === 'Escape') setOpen(false) }
|
|
363
|
+
document.addEventListener('pointerdown', onPointer)
|
|
364
|
+
document.addEventListener('keydown', onKey)
|
|
365
|
+
return () => {
|
|
366
|
+
document.removeEventListener('pointerdown', onPointer)
|
|
367
|
+
document.removeEventListener('keydown', onKey)
|
|
217
368
|
}
|
|
218
|
-
document.addEventListener('pointerdown', close)
|
|
219
|
-
return () => document.removeEventListener('pointerdown', close)
|
|
220
369
|
}, [open])
|
|
221
370
|
|
|
222
371
|
if (!switched) return null
|
|
223
372
|
const tokens = typeof usage?.tokens === 'number' ? usage.tokens : 0
|
|
224
373
|
if (tokens === 0) return null
|
|
225
374
|
const money = formatMoney(usage.costUsd)
|
|
226
|
-
const
|
|
375
|
+
const unpriced = usage.incomplete === true || money === ''
|
|
227
376
|
const cacheHit = tokens === 0 ? 0 : Math.round((usage.cacheRead ?? 0) / tokens * 100)
|
|
228
377
|
const models = Array.isArray(usage.models) ? usage.models : []
|
|
229
378
|
|
|
@@ -245,7 +394,7 @@ function CostPill(props) {
|
|
|
245
394
|
style: {
|
|
246
395
|
display: 'flex',
|
|
247
396
|
height: '6px',
|
|
248
|
-
margin: '
|
|
397
|
+
margin: '0 0 12px',
|
|
249
398
|
borderRadius: '999px',
|
|
250
399
|
overflow: 'hidden',
|
|
251
400
|
background: 'color-mix(in srgb, CanvasText 10%, transparent)',
|
|
@@ -256,49 +405,27 @@ function CostPill(props) {
|
|
|
256
405
|
segment(usage.cacheWrite, 62, 'bar-cache-write'),
|
|
257
406
|
segment(usage.output, 82, 'bar-output'))
|
|
258
407
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
408
|
+
const reading = money === '' ? 'Not priced' : '≈' + money
|
|
409
|
+
const full = reading + (unpriced ? ' — partially priced: some usage has no published rate' : '')
|
|
410
|
+
const pillStyle = { ...PILL_STYLE, ...(hovered || open
|
|
411
|
+
? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.12))', color: 'var(--dsw-alias-label-secondary, inherit)' }
|
|
412
|
+
: {}) }
|
|
413
|
+
|
|
414
|
+
const card = React.createElement('div', {
|
|
415
|
+
ref: panel,
|
|
416
|
+
role: 'dialog',
|
|
417
|
+
'aria-label': 'Estimated session cost',
|
|
418
|
+
'data-polish-cost-card': '',
|
|
419
|
+
style: { ...PANEL_STYLE, ...(pos === null ? { left: 12, bottom: 12, visibility: 'hidden' } : pos) },
|
|
262
420
|
},
|
|
263
|
-
React.createElement('
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
style: {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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,
|
|
421
|
+
React.createElement('div', { style: PANEL_TITLE_STYLE },
|
|
422
|
+
React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
|
|
423
|
+
React.createElement(CoinGlyph),
|
|
424
|
+
'Estimated session cost'),
|
|
425
|
+
React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, money === '' ? 'Not priced' : money)),
|
|
426
|
+
React.createElement('div', { style: PANEL_RULE_STYLE, 'aria-hidden': true }),
|
|
427
|
+
bar,
|
|
428
|
+
React.createElement('dl', { style: PANEL_DETAILS_STYLE },
|
|
302
429
|
detailRow('Cache hit', cacheHit + '%', 'cache-hit'),
|
|
303
430
|
detailRow('Uncached input', formatTokens(usage.uncached), 'uncached'),
|
|
304
431
|
detailRow('Cached input', formatTokens(usage.cacheRead), 'cached'),
|
|
@@ -306,13 +433,39 @@ function CostPill(props) {
|
|
|
306
433
|
detailRow('Output', formatTokens(usage.output), 'output'),
|
|
307
434
|
detailRow('Model', modelName, 'model'),
|
|
308
435
|
detailRow('Provider', providerName, 'provider'),
|
|
309
|
-
detailRow('Requests', String(usage.requests ?? 0), 'requests'),
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
: null
|
|
436
|
+
detailRow('Requests', String(usage.requests ?? 0), 'requests')),
|
|
437
|
+
unpriced
|
|
438
|
+
? React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
|
|
439
|
+
'Partially priced: some usage in this session has no published rate, so this is a lower bound.')
|
|
440
|
+
: null,
|
|
441
|
+
React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, COST_FOOTNOTE))
|
|
442
|
+
|
|
443
|
+
return React.createElement('span', { ref: root, style: PILL_ANCHOR_STYLE, 'data-composer-stat': 'polish-cost' },
|
|
444
|
+
React.createElement('button', {
|
|
445
|
+
type: 'button',
|
|
446
|
+
className: undefined,
|
|
447
|
+
'aria-haspopup': 'dialog',
|
|
448
|
+
'aria-expanded': open,
|
|
449
|
+
'aria-label': full,
|
|
450
|
+
title: full,
|
|
451
|
+
onClick: () => setOpen(!open),
|
|
452
|
+
onMouseEnter: () => setHovered(true),
|
|
453
|
+
onMouseLeave: () => setHovered(false),
|
|
454
|
+
style: pillStyle,
|
|
455
|
+
},
|
|
456
|
+
React.createElement(CoinGlyph),
|
|
457
|
+
React.createElement('span', { style: PILL_LABEL_STYLE }, reading),
|
|
458
|
+
unpriced ? React.createElement('span', { style: { color: 'var(--dsw-alias-separator-primary, inherit)' } }, '*') : null),
|
|
459
|
+
open && typeof document !== 'undefined' ? ReactDOM.createPortal(card, document.body) : null)
|
|
314
460
|
}
|
|
315
461
|
|
|
462
|
+
/**
|
|
463
|
+
* How long to wait before trying the balance again, in milliseconds. A Shell that
|
|
464
|
+
* has not finished signing in answers the first read with nothing, and a line that
|
|
465
|
+
* gives up after one attempt would stay empty for the life of the page.
|
|
466
|
+
*/
|
|
467
|
+
const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
|
|
468
|
+
|
|
316
469
|
/**
|
|
317
470
|
* The remaining-balance line in the sidebar foot.
|
|
318
471
|
*
|
|
@@ -330,15 +483,43 @@ function BalanceLine(props) {
|
|
|
330
483
|
React.useEffect(() => {
|
|
331
484
|
if (!enabled || typeof read !== 'function') return undefined
|
|
332
485
|
let live = true
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
486
|
+
let attempt = 0
|
|
487
|
+
let timer
|
|
488
|
+
const look = () => {
|
|
489
|
+
Promise.resolve()
|
|
490
|
+
.then(() => read())
|
|
491
|
+
.then(view => {
|
|
492
|
+
if (!live) return
|
|
493
|
+
const next = walletOf(view)
|
|
494
|
+
if (next !== undefined) {
|
|
495
|
+
setWallet(next)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
if (attempt < BALANCE_RETRY_DELAYS.length) {
|
|
499
|
+
timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
|
|
500
|
+
attempt += 1
|
|
501
|
+
}
|
|
502
|
+
}, () => {
|
|
503
|
+
if (!live) return
|
|
504
|
+
if (attempt < BALANCE_RETRY_DELAYS.length) {
|
|
505
|
+
timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
|
|
506
|
+
attempt += 1
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
look()
|
|
511
|
+
// A window that was hidden through sign-in comes back with an answer the
|
|
512
|
+
// first attempt could not have had; retry from the start when it regains focus.
|
|
513
|
+
const onFocus = () => {
|
|
514
|
+
attempt = 0
|
|
515
|
+
clearTimeout(timer)
|
|
516
|
+
look()
|
|
517
|
+
}
|
|
518
|
+
if (typeof window !== 'undefined') window.addEventListener('focus', onFocus)
|
|
340
519
|
return () => {
|
|
341
520
|
live = false
|
|
521
|
+
clearTimeout(timer)
|
|
522
|
+
if (typeof window !== 'undefined') window.removeEventListener('focus', onFocus)
|
|
342
523
|
}
|
|
343
524
|
}, [enabled, read])
|
|
344
525
|
if (!enabled) return null
|
|
@@ -367,6 +548,74 @@ function BalanceLine(props) {
|
|
|
367
548
|
}, props.wide === false ? money : 'Balance ' + money)
|
|
368
549
|
}
|
|
369
550
|
|
|
551
|
+
/**
|
|
552
|
+
* The workspace a Session belongs to.
|
|
553
|
+
*
|
|
554
|
+
* Occupants of `sidebar.workspaces.session.menu.item` address their data by
|
|
555
|
+
* Session id and must not bind or retain the Session, so the workspace is read
|
|
556
|
+
* from the Workspace snapshot at render time and never memoized.
|
|
557
|
+
*
|
|
558
|
+
* @param workspaces - the Shell's Workspace service, when it is loaded.
|
|
559
|
+
* @param sessionId - the row's Session.
|
|
560
|
+
* @returns the owning workspace, or undefined when it cannot be resolved.
|
|
561
|
+
*/
|
|
562
|
+
function workspaceOf(workspaces, sessionId) {
|
|
563
|
+
if (workspaces === undefined || workspaces === null || sessionId === undefined) return undefined
|
|
564
|
+
let items
|
|
565
|
+
try {
|
|
566
|
+
items = workspaces.list.getSnapshot().items
|
|
567
|
+
} catch {
|
|
568
|
+
return undefined
|
|
569
|
+
}
|
|
570
|
+
if (!Array.isArray(items)) return undefined
|
|
571
|
+
return items.find(item => Array.isArray(item.sessionIds)
|
|
572
|
+
&& item.sessionIds.indexOf(sessionId) !== -1
|
|
573
|
+
&& typeof item.path === 'string'
|
|
574
|
+
&& item.path !== '')
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* "Open workspace folder": one row in a Session's "…" menu.
|
|
579
|
+
*
|
|
580
|
+
* It renders nothing when the switch is off or the Session has no workspace the
|
|
581
|
+
* Host could open, because a menu row that cannot act is worse than no row.
|
|
582
|
+
*
|
|
583
|
+
* @param props - the row's Session id, our lazy `configForms`/`workspaces`, the
|
|
584
|
+
* opener, and the slot's injected `useMenuOpenState` hook.
|
|
585
|
+
* @returns the menu row, or null.
|
|
586
|
+
*/
|
|
587
|
+
function OpenFolderMenuItem(props) {
|
|
588
|
+
const enabled = useSwitch(props.configForms, 'openWorkspaceFolder', true)
|
|
589
|
+
// Hooks run unconditionally; the row simply renders nothing when it cannot act.
|
|
590
|
+
const menu = typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState() : undefined
|
|
591
|
+
const closeMenu = Array.isArray(menu) && typeof menu[1] === 'function' ? menu[1] : undefined
|
|
592
|
+
const workspace = enabled ? workspaceOf(props.workspaces, props.sessionId) : undefined
|
|
593
|
+
if (!enabled || workspace === undefined) return null
|
|
594
|
+
const label = 'Open workspace folder'
|
|
595
|
+
const onSelect = () => {
|
|
596
|
+
if (closeMenu !== undefined) closeMenu(false)
|
|
597
|
+
const open = props.openFolder
|
|
598
|
+
if (typeof open !== 'function') return
|
|
599
|
+
void Promise.resolve()
|
|
600
|
+
.then(() => open(workspace))
|
|
601
|
+
.catch(error => {
|
|
602
|
+
// A menu row that silently does nothing is unfixable in the field, so the
|
|
603
|
+
// failure is reported instead of dropped. This is the only diagnostic the
|
|
604
|
+
// bundle writes.
|
|
605
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
606
|
+
console.warn('[aryacode-polish] open workspace folder failed:', error && error.message ? error.message : error)
|
|
607
|
+
}
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
if (primitives !== undefined && typeof primitives.MenuItemButton === 'function') {
|
|
611
|
+
const icon = typeof primitives.IconFolderOpenRegular === 'function'
|
|
612
|
+
? React.createElement(primitives.IconFolderOpenRegular)
|
|
613
|
+
: undefined
|
|
614
|
+
return React.createElement(primitives.MenuItemButton, { icon, onSelect }, label)
|
|
615
|
+
}
|
|
616
|
+
return React.createElement('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label)
|
|
617
|
+
}
|
|
618
|
+
|
|
370
619
|
return {
|
|
371
620
|
inject: ['slots'],
|
|
372
621
|
/**
|
|
@@ -392,12 +641,42 @@ return {
|
|
|
392
641
|
order: 2,
|
|
393
642
|
inject: () => ({ configForms }),
|
|
394
643
|
}, CostPill))
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
644
|
+
// The account Remote namespace is optional: a Shell with no account still gets
|
|
645
|
+
// the Configure page and the cost pill, and only loses the balance line.
|
|
646
|
+
ctx.inject(['remote', 'remote.account'], (accountCtx) => {
|
|
647
|
+
const loadBalance = () => readBalance(accountCtx, CLIENT_VERSION)
|
|
648
|
+
accountCtx.slots.inject('sidebar.footer.action', () => accountCtx.slots.register({
|
|
649
|
+
name: 'sidebar.footer.action',
|
|
650
|
+
id: 'polish-balance',
|
|
651
|
+
order: 3,
|
|
652
|
+
inject: () => ({ configForms, loadBalance }),
|
|
653
|
+
}, BalanceLine))
|
|
654
|
+
})
|
|
655
|
+
// The Workspace service and the Session Remote are both required for the menu
|
|
656
|
+
// row: it reads the folder from the first and opens it through the second.
|
|
657
|
+
// They are declared on the inject list because a Remote namespace may not be
|
|
658
|
+
// read through `ctx.get` — the Shell's guard refuses it.
|
|
659
|
+
ctx.inject(['workspaces', 'remote', 'remote.session'], (workspaceCtx) => {
|
|
660
|
+
const openFolder = async (workspace) => {
|
|
661
|
+
const remote = workspaceCtx.remote.session
|
|
662
|
+
// A Remote method answers an envelope (`{ ok, value }`), not the bare
|
|
663
|
+
// return value; a Shell that answered a plain boolean is tolerated too.
|
|
664
|
+
const answer = await remote.canOpenWorkspacePath()
|
|
665
|
+
const available = answer !== null && typeof answer === 'object'
|
|
666
|
+
? answer.ok === true && answer.value === true
|
|
667
|
+
: answer === true
|
|
668
|
+
if (!available) throw new Error('this Host cannot open a folder')
|
|
669
|
+
const result = await remote.openWorkspacePath({ path: workspace.path })
|
|
670
|
+
if (result !== null && typeof result === 'object' && 'ok' in result && result.ok !== true) {
|
|
671
|
+
throw new Error('the Host refused to open the folder')
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
workspaceCtx.slots.inject('sidebar.workspaces.session.menu.item', () => workspaceCtx.slots.register({
|
|
675
|
+
name: 'sidebar.workspaces.session.menu.item',
|
|
676
|
+
id: 'polish-open-folder',
|
|
677
|
+
order: 460,
|
|
678
|
+
inject: () => ({ configForms, openFolder, workspaces: workspaceCtx.get('workspaces') }),
|
|
679
|
+
}, OpenFolderMenuItem))
|
|
680
|
+
})
|
|
402
681
|
},
|
|
403
682
|
}
|
package/client/switches.json
CHANGED
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
"label": "Account balance",
|
|
13
13
|
"description": "Show the remaining account balance in the sidebar foot, above the account row.",
|
|
14
14
|
"defaultOn": true
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"key": "openWorkspaceFolder",
|
|
18
|
+
"label": "Open workspace folder",
|
|
19
|
+
"description": "Add an \u201cOpen workspace folder\u201d row to a Session\u2019s menu that opens its workspace in the file browser.",
|
|
20
|
+
"defaultOn": true
|
|
15
21
|
}
|
|
16
22
|
]
|
|
17
23
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Open a workspace folder in the file browser
|
|
2
|
+
|
|
3
|
+
## What ships
|
|
4
|
+
|
|
5
|
+
A row in a Session's "…" menu: **Open workspace folder**. It resolves the workspace
|
|
6
|
+
that owns the Session and asks the Host to open that directory in the platform file
|
|
7
|
+
manager (Explorer on Windows, Finder on macOS, the desktop's file manager elsewhere).
|
|
8
|
+
|
|
9
|
+
- Switch: `openWorkspaceFolder` (on by default).
|
|
10
|
+
- Seat: `sidebar.workspaces.session.menu.item`, registered as `polish-open-folder`
|
|
11
|
+
at `order: 460`, after the shipped `session-id` row at `450`.
|
|
12
|
+
- Host call: `ctx.remote.session.openWorkspacePath({ path })`, gated by
|
|
13
|
+
`ctx.remote.session.canOpenWorkspacePath()`.
|
|
14
|
+
- Path source: the Workspace snapshot (`ctx.workspaces.list`), matched through
|
|
15
|
+
`WorkspaceView.sessionIds`.
|
|
16
|
+
|
|
17
|
+
The row renders nothing when the switch is off, when the Session belongs to no
|
|
18
|
+
workspace, or when the Host reports it cannot open a path. A menu row that cannot
|
|
19
|
+
act is worse than no row.
|
|
20
|
+
|
|
21
|
+
## What cannot ship, and why
|
|
22
|
+
|
|
23
|
+
The requested surface was **right-click on the workspace row itself**, with a menu
|
|
24
|
+
like the one AryaAI has (`Open in Explorer`, `Rename`, `Manage folders`, `Delete
|
|
25
|
+
workspace`). That is not reachable from a plugin, for three independent reasons:
|
|
26
|
+
|
|
27
|
+
1. The workspace row is drawn by `packages/client/ui-workspace/src/client/rows/Rows.tsx`.
|
|
28
|
+
The row element carries `data-row-key`, `role="treeitem"` and `onClick`, and has
|
|
29
|
+
no `onContextMenu`. Its "…" button opens a menu whose entries are hardcoded
|
|
30
|
+
(`rename`, `delete.workspace`).
|
|
31
|
+
2. No slot exists for per-workspace menu entries. The only workspace-related list
|
|
32
|
+
slots are `sidebar.workspaces.session.menu.item` and
|
|
33
|
+
`sidebar.workspaces.session.row.action`, both scoped to a **Session** row. The
|
|
34
|
+
single `sidebar.workspaces` slot replaces the whole browser
|
|
35
|
+
(`replaceRisk: 'shadows-shipped-ui'`), which would mean rebuilding the shipped
|
|
36
|
+
sidebar.
|
|
37
|
+
3. Native menus are closed to plugins: `apps/desktop/src/main.ts` allows only the
|
|
38
|
+
`application` and `edit` roles from the main window's main frame, and the
|
|
39
|
+
desktop IPC surface (`apps/desktop/src/ipc.ts`, `DshDesktopProductApi`) exposes
|
|
40
|
+
no open/reveal call.
|
|
41
|
+
|
|
42
|
+
Attaching an `onContextMenu` to the row's DOM by selector would be a hack against
|
|
43
|
+
private markup — the thing this bundle's ground rules forbid, and the first thing
|
|
44
|
+
to break when the sidebar changes. The honest options were:
|
|
45
|
+
|
|
46
|
+
- **Ship on the Session row menu** (chosen): documented slot, supported API,
|
|
47
|
+
survives harness updates.
|
|
48
|
+
- Change the shell to declare `sidebar.workspaces.menu.item` and render it from the
|
|
49
|
+
workspace row, then release a harness patch. Declined: the owner wants to keep
|
|
50
|
+
taking harness updates from public releases.
|
|
51
|
+
- Park the feature. Declined: the underlying capability (open a workspace folder)
|
|
52
|
+
is worth shipping now.
|
|
53
|
+
|
|
54
|
+
If the harness ever declares a workspace-scoped menu slot, this feature should move
|
|
55
|
+
there: the Host call and the path resolution stay exactly as they are, and only the
|
|
56
|
+
registration line changes.
|
|
57
|
+
|
|
58
|
+
## Evidence for the next reader
|
|
59
|
+
|
|
60
|
+
- Slot contract: `packages/client/ui-workspace/src/client/contract/slots.ts:152-205`.
|
|
61
|
+
- Shipped row to copy: `packages/client/ui-workspace/src/client/session-actions/SessionId.tsx`.
|
|
62
|
+
- Remote methods: `packages/api/session-controller/src/index.ts:336-339`
|
|
63
|
+
(`canOpenWorkspacePath`) and `:357-367` (`openWorkspacePath`).
|
|
64
|
+
- Windows reveal path: `packages/util/native-command/src/path-opener.ts:307`
|
|
65
|
+
(`runExplorer(['/select,', …])`); `open-in-app` is mounted in the web bundle at
|
|
66
|
+
`packages/bundle/web-app/cordis.patch.yml:73-81`, so this works under `start:web`.
|
|
67
|
+
- Workspace view: `packages/api/workspace-controller/src/types.ts:18-30`.
|
package/entries/registry.js
CHANGED
|
@@ -46,6 +46,12 @@ export const features = [
|
|
|
46
46
|
description: 'Show the remaining account balance in the sidebar foot, above the account row.',
|
|
47
47
|
defaultOn: true,
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
id: 'openWorkspaceFolder',
|
|
51
|
+
label: 'Open workspace folder',
|
|
52
|
+
description: 'Add an "Open workspace folder" row to a Session\u2019s menu that opens its workspace in the file browser.',
|
|
53
|
+
defaultOn: true,
|
|
54
|
+
},
|
|
49
55
|
]
|
|
50
56
|
|
|
51
57
|
/**
|