@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/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
- /** One label/value line of the breakdown card. */
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('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))
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 close = event => {
216
- if (root.current !== null && !root.current.contains(event.target)) setOpen(false)
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 label = money === '' ? 'Not priced' : (usage.incomplete ? '≈' + money + ' partially priced' : '≈' + money)
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: '10px 0 12px',
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
- return React.createElement('div', {
260
- ref: root,
261
- style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
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('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,
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
- 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)
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
- Promise.resolve()
334
- .then(() => read())
335
- .then(view => {
336
- if (live) setWallet(walletOf(view))
337
- }, () => {
338
- if (live) setWallet(undefined)
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
- 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))
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
  }
@@ -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`.
@@ -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
  /**