@aryacode/dsh-aryacode-polish 0.3.9 → 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 CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.4] - 2026-10-27
4
+
5
+ ### Fixed
6
+
7
+ - **The row reported "this Host cannot open a folder" on a Host that can.** A Remote
8
+ method answers an envelope — `{ ok, value }` — not the bare return value, so
9
+ `canOpenWorkspacePath()` never looked like `true`. The row now reads
10
+ `ok === true && value === true`, tolerates a plain boolean, and checks the
11
+ `ok` flag of the open call itself instead of trusting that no throw means opened.
12
+
13
+ ## [0.4.3] - 2026-10-27
14
+
15
+ ### Fixed
16
+
17
+ - **The row still could not open anything.** 0.4.2 declared `remote.session` but the
18
+ guard checks each namespace on its own: reading `ctx.remote.session` needs both
19
+ `remote` and `remote.session` on the inject list, so the call failed one level
20
+ earlier. The registration now declares `['workspaces', 'remote', 'remote.session']`.
21
+
22
+ ## [0.4.2] - 2026-10-27
23
+
24
+ ### Fixed
25
+
26
+ - **The row did nothing when clicked.** The Shell refuses `ctx.get('remote.session')`
27
+ — a Remote namespace may only be read through a declared injection — so the open
28
+ call failed before it reached the Host. The registration now declares
29
+ `ctx.inject(['workspaces', 'remote.session'], …)` and calls the namespace the
30
+ injected context hands it. Found by the row reporting its own failure (0.4.1).
31
+
32
+ ## [0.4.1] - 2026-10-27
33
+
34
+ ### Changed
35
+
36
+ - The **Open workspace folder** row reports a failed open instead of doing nothing.
37
+ A menu row that silently ignores the click is impossible to diagnose in the
38
+ field; the bundle now writes one console.warn naming the failing step (no
39
+ Session Remote, a Host that cannot open a path, or the Remote error itself).
40
+
41
+ ## [0.4.0] - 2026-10-27
42
+
43
+ ### Added
44
+
45
+ - **Open workspace folder.** A Session's "…" menu gains an **Open workspace folder**
46
+ row that opens the Session's workspace directory in Explorer/Finder through the
47
+ Session Remote (`openWorkspacePath`), gated by `canOpenWorkspacePath` and resolved
48
+ from the Workspace snapshot. New switch `openWorkspaceFolder`, on by default, and
49
+ the row hides itself when the Session has no workspace or the Host cannot open a
50
+ path.
51
+ - `docs/workspace-folder.md` records why the requested surface — right-clicking the
52
+ workspace row itself — is not reachable from a plugin: that row has no context
53
+ menu, the workspace-scoped menu list slot does not exist, and native menus and
54
+ desktop IPC are closed to plugins. The bundle ships on the documented Session-row
55
+ slot rather than hijacking private markup, and the note says where the feature
56
+ should move if a workspace-scoped slot ever lands.
57
+
3
58
  ## [0.3.9] - 2026-10-27
4
59
 
5
60
  ### Changed
package/README.md CHANGED
@@ -7,7 +7,7 @@ installed into and patches nothing. It is one package, one Plugins row, and one
7
7
  switch per feature, so a refinement that turns out to be wrong for a machine can
8
8
  be turned off without uninstalling anything.
9
9
 
10
- Status: **two features shipped.** The version is the package's, and the card
10
+ Status: **three features shipped.** The version is the package's, and the card
11
11
  header already shows it.
12
12
 
13
13
  ## What it is
@@ -32,6 +32,7 @@ Configure page automatically.
32
32
  | --- | --- | --- |
33
33
  | **Estimated session cost** (`sessionCost`) | On | A `≈$0.39` pill in the composer dock that opens a breakdown card — cache hit, uncached input, cached input, output, requests, and the estimate — priced from the session's recorded usage at the published DeepSeek rates. |
34
34
  | **Account balance** (`accountBalance`) | On | The account's remaining wallet (`$17.33`) in the sidebar foot, above the account row. Renders nothing when the Host exposes no wallet, so it never shows a number nobody gave it. |
35
+ | **Open workspace folder** (`openWorkspaceFolder`) | On | An **Open workspace folder** row in a Session's "…" menu that opens the Session's workspace in Explorer/Finder. Renders nothing when the Session has no workspace or the Host cannot open a path. See [Open a workspace folder](docs/workspace-folder.md) for why the request had to land here instead of on the workspace row itself. |
35
36
 
36
37
  ### How the cost is computed
37
38
 
@@ -130,7 +131,7 @@ client/switches.json the browser half of the seam — one entry per switch
130
131
  scripts/build-client.mjs wraps client/face.js into lib/client.js
131
132
  lib/client.js generated: the bundle the Shell loads
132
133
  locale/ card title and description (en, zh)
133
- docs/ parked investigations
134
+ docs/ feature notes and parked investigations
134
135
  test/ node --test specs
135
136
  ```
136
137
 
package/client/face.js CHANGED
@@ -17,6 +17,19 @@
17
17
  const React = require('react')
18
18
  const ReactDOM = require('react-dom')
19
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
+ })()
32
+
20
33
  /** Shared spacing for the page's prose. */
21
34
  const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
22
35
 
@@ -535,6 +548,74 @@ function BalanceLine(props) {
535
548
  }, props.wide === false ? money : 'Balance ' + money)
536
549
  }
537
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
+
538
619
  return {
539
620
  inject: ['slots'],
540
621
  /**
@@ -571,5 +652,31 @@ return {
571
652
  inject: () => ({ configForms, loadBalance }),
572
653
  }, BalanceLine))
573
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
+ })
574
681
  },
575
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
  /**
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.9"
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
@@ -166,6 +172,19 @@ window.__ModuleLoader__.load({
166
172
  const React = require('react')
167
173
  const ReactDOM = require('react-dom')
168
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
+ })()
187
+
169
188
  /** Shared spacing for the page's prose. */
170
189
  const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
171
190
 
@@ -684,6 +703,74 @@ window.__ModuleLoader__.load({
684
703
  }, props.wide === false ? money : 'Balance ' + money)
685
704
  }
686
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
+
687
774
  return {
688
775
  inject: ['slots'],
689
776
  /**
@@ -720,6 +807,32 @@ window.__ModuleLoader__.load({
720
807
  inject: () => ({ configForms, loadBalance }),
721
808
  }, BalanceLine))
722
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
+ })
723
836
  },
724
837
  }
725
838
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.3.9",
3
+ "version": "0.4.4",
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",