@aryacode/dsh-aryacode-polish 0.5.0 → 0.6.2

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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.2] - 2026-10-28
4
+
5
+ ### Fixed
6
+
7
+ - **The "+" menu and the GitHub dialog are opaque again in the desktop app.**
8
+ Both are portaled to `document.body`, which puts them outside the themed tree,
9
+ and they painted themselves with the harness menu fill `--dsw-specific-menu`
10
+ (`#f8f9fa94`, 58% alpha) relying on `backdrop-filter` to make that read solid.
11
+ The AryaCode desktop build does not composite that blur, so the page showed
12
+ through the panel. Every portaled surface now lays that fill over an opaque
13
+ `Canvas` base and keeps the blur only where the platform honours it.
14
+
15
+ ## [0.6.1] - 2026-10-28
16
+
17
+ ### Fixed
18
+
19
+ - **"On this PC" opens the real Windows folder dialog in the desktop app again.**
20
+ The desktop preload exposes the OS chooser as `__DSH_DIRECTORY_PICKER__.pick()`
21
+ — the same bridge the shipped native picker uses — and the flow now calls it
22
+ directly, so picking a folder behaves exactly like the picker it stands in for.
23
+ The in-app folder list is now only the browser fallback, where no such bridge
24
+ exists and the composed picker's `pick` would wait on a dialog that cannot open.
25
+
26
+ ## [0.6.0] - 2026-10-28
27
+
28
+ ### Changed
29
+
30
+ - **Adding a workspace moved into the Workspaces "+" menu.** A new switch
31
+ (`workspaceSources`) occupies `sidebar.workspaces.directoryFlow`, which is what
32
+ makes the Shell's **Add workspace…** row appear at all, and stands in for the
33
+ folder picker with the source menu the owner asked for: **On this PC** (a folder
34
+ browser driven by this bundle's own `api/polish.folders` route) and **GitHub
35
+ repository…** (the existing dialog). A finished clone hands its path to the
36
+ flow's `onPicked`, so the Shell creates the workspace exactly as it does for a
37
+ picked folder.
38
+
39
+ ### Added
40
+
41
+ - The sidebar foot now carries the GitHub sign-in state next to the balance
42
+ (`GitHub · <login>` / `GitHub · signed out`); clicking it opens the repository
43
+ dialog. It replaced the foot's add-workspace row, which the "+" menu supersedes.
44
+
45
+ ### Fixed
46
+
47
+ - "On this PC" lists folders instead of waiting on a chooser that cannot open. A
48
+ composed directory picker serves one capability, `browse` or `native`, and the
49
+ browser build composes the one with no chooser to show, so the previous
50
+ `directoryPicker.pick()` call simply never settled. The flow now walks folders
51
+ through its own route: roots, a `..` row for going back, directories only, and
52
+ hidden or system entries filtered out.
53
+
3
54
  ## [0.5.0] - 2026-10-27
4
55
 
5
56
  ### Added
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: **four features shipped.** The version is the package's, and the card
10
+ Status: **five features shipped.** The version is the package's, and the card
11
11
  header already shows it.
12
12
 
13
13
  ## What it is
@@ -33,7 +33,8 @@ Configure page automatically.
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
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. |
36
- | **Add a workspace from GitHub** (`githubWorkspace`) | On | **Add workspace from GitHub** in the sidebar foot: the dialog lists the repositories your GitHub CLI session can clone, and cloning one creates the workspace. The token never enters the browser — it is read on the Host, used only as git's http auth header, and never stored. See [GitHub workspaces](docs/github-workspaces.md). |
36
+ | **Add a workspace from GitHub** (`githubWorkspace`) | On | Adds the GitHub half of the workspace sources: the dialog lists the repositories your GitHub CLI session can clone, and cloning one creates the workspace. The token never enters the browser — it is read on the Host, used only as git's http auth header, and never stored. The sidebar foot carries the sign-in state (`GitHub · <login>`). See [GitHub workspaces](docs/github-workspaces.md). |
37
+ | **Add a workspace from this PC or GitHub** (`workspaceSources`) | On | The Workspaces **+** button gets a source menu: **On this PC** (the Host's OS chooser, or a folder list when the composition has no native chooser) and **GitHub repository…**. Off, the **+** button goes back to the shipped folder picker. |
37
38
 
38
39
  ### How the cost is computed
39
40
 
package/client/face.js CHANGED
@@ -252,6 +252,24 @@ const PILL_STYLE = {
252
252
  /** The label half of the pill, cut with an ellipsis rather than wrapped. */
253
253
  const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
254
254
 
255
+ /**
256
+ * The menu skin of the harness, made opaque on purpose.
257
+ *
258
+ * `--dsw-specific-menu` is a translucent fill (`#f8f9fa94`) that only reads as a
259
+ * solid surface because the harness pairs it with `backdrop-filter`. A plugin
260
+ * face has to portal its panels to `document.body`, and in the AryaCode desktop
261
+ * build that blur does not composite: the panel then showed the page behind it
262
+ * through a 58% fill. So the token stays as a tint over an opaque backdrop
263
+ * colour, and the blur is kept only where the platform actually honours it.
264
+ */
265
+ const PANEL_SURFACE = {
266
+ backgroundColor: 'Canvas',
267
+ backgroundImage:
268
+ 'linear-gradient(var(--dsw-specific-menu, rgba(248, 249, 250, 0.94)), var(--dsw-specific-menu, rgba(248, 249, 250, 0.94)))',
269
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
270
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))',
271
+ }
272
+
255
273
  /** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
256
274
  const PANEL_STYLE = {
257
275
  position: 'fixed',
@@ -261,10 +279,8 @@ const PANEL_STYLE = {
261
279
  minWidth: 'min(300px, calc(100vw - 24px))',
262
280
  maxWidth: 'min(440px, calc(100vw - 24px))',
263
281
  padding: '16px',
264
- border: 0,
265
282
  borderRadius: 'var(--dsw-radius-lg, 12px)',
266
- background: 'var(--dsw-specific-menu, Canvas)',
267
- backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
283
+ ...PANEL_SURFACE,
268
284
  boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
269
285
  fontSize: '12px',
270
286
  lineHeight: '18px',
@@ -466,6 +482,7 @@ function CostPill(props) {
466
482
  */
467
483
  const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
468
484
 
485
+
469
486
  /**
470
487
  * The remaining-balance line in the sidebar foot.
471
488
  *
@@ -627,7 +644,12 @@ const GITHUB_ROUTE = 'api/polish.github'
627
644
  */
628
645
  const githubStore = (() => {
629
646
  let open = false
647
+ // Set when the dialog was opened from the add-workspace flow: the flow owns
648
+ // workspace creation through `onPicked`, so a clone hands it the path rather
649
+ // than calling the Workspace service itself.
650
+ let picker
630
651
  const listeners = new Set()
652
+ const publish = () => { for (const listener of listeners) listener() }
631
653
  return {
632
654
  isOpen: () => open,
633
655
  subscribe(listener) {
@@ -637,7 +659,21 @@ const githubStore = (() => {
637
659
  set(next) {
638
660
  if (open === next) return
639
661
  open = next
640
- for (const listener of listeners) listener()
662
+ if (!next) picker = undefined
663
+ publish()
664
+ },
665
+ /** Open the dialog, optionally on behalf of the add-workspace flow. */
666
+ open(onPicked) {
667
+ picker = onPicked
668
+ if (open) return
669
+ open = true
670
+ publish()
671
+ },
672
+ /** Take the flow's `onPicked` once, so a second clone cannot reuse it. */
673
+ takePicker() {
674
+ const current = picker
675
+ picker = undefined
676
+ return current
641
677
  },
642
678
  }
643
679
  })()
@@ -662,6 +698,28 @@ async function githubCall(action, extra) {
662
698
  return payload.value
663
699
  }
664
700
 
701
+ /** The Host route the folder browser talks over. Document-relative, like every app route. */
702
+ const FOLDERS_ROUTE = 'api/polish.folders'
703
+
704
+ /**
705
+ * Post one action to the folder route.
706
+ * @param body - `{ action: 'roots' }` or `{ action: 'ls', path }`.
707
+ * @returns the route's listing, or throws with its own message.
708
+ */
709
+ async function foldersCall(body) {
710
+ const response = await fetch(FOLDERS_ROUTE, {
711
+ method: 'POST',
712
+ headers: { 'content-type': 'application/json' },
713
+ body: JSON.stringify(body),
714
+ })
715
+ const payload = await response.json().catch(() => undefined)
716
+ if (payload === undefined || typeof payload !== 'object') {
717
+ throw new Error(`the Host answered ${String(response.status)}`)
718
+ }
719
+ if (payload.error !== undefined) throw new Error(String(payload.error))
720
+ return payload
721
+ }
722
+
665
723
  /** Modal backdrop and panel skin, matching the Shell's own dialogs. */
666
724
  const GITHUB_BACKDROP_STYLE = {
667
725
  position: 'fixed',
@@ -785,7 +843,15 @@ function GitHubPanel(props) {
785
843
  setError('')
786
844
  Promise.resolve()
787
845
  .then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
788
- .then(result => Promise.resolve(props.createWorkspace(result.path)).then(() => result))
846
+ // The add-workspace flow owns creation when it opened this dialog; a
847
+ // dialog opened from the footer creates the workspace itself.
848
+ .then(result => {
849
+ const onPicked = githubStore.takePicker()
850
+ const land = onPicked === undefined
851
+ ? Promise.resolve(props.createWorkspace(result.path))
852
+ : Promise.resolve(onPicked(result.path))
853
+ return land.then(() => result)
854
+ })
789
855
  .then(result => {
790
856
  setBusy('')
791
857
  githubStore.set(false)
@@ -862,59 +928,326 @@ function GitHubPanel(props) {
862
928
  , document.body)
863
929
  }
864
930
 
931
+ /** The compact footer-row skin shared by the status line. */
932
+ const FOOT_ROW_STYLE = {
933
+ display: 'flex',
934
+ alignItems: 'center',
935
+ gap: '6px',
936
+ maxWidth: '100%',
937
+ minWidth: 0,
938
+ padding: '2px 6px',
939
+ border: 'none',
940
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
941
+ background: 'transparent',
942
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
943
+ font: 'inherit',
944
+ fontSize: '12px',
945
+ lineHeight: '18px',
946
+ fontVariantNumeric: 'tabular-nums',
947
+ whiteSpace: 'nowrap',
948
+ overflow: 'hidden',
949
+ textOverflow: 'ellipsis',
950
+ cursor: 'pointer',
951
+ }
952
+
865
953
  /**
866
- * The footer row that opens the GitHub dialog.
954
+ * The footer status line: which GitHub session the "+" menu will clone with.
955
+ *
956
+ * It reads the same `status` action the dialog does, so it costs one request
957
+ * when the Shell loads and one whenever the window regains focus.
958
+ *
867
959
  * @param props - our lazy `configForms`.
868
960
  * @returns the row, or null when the feature is switched off.
869
961
  */
870
- function GithubEntry(props) {
962
+ function GithubStatus(props) {
871
963
  const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
964
+ const [status, setStatus] = React.useState(undefined)
965
+ React.useEffect(() => {
966
+ if (!enabled) return undefined
967
+ let live = true
968
+ const read = () => {
969
+ Promise.resolve()
970
+ .then(() => githubCall('status'))
971
+ .then(next => { if (live) setStatus(next) })
972
+ .catch(() => { if (live) setStatus({ signedIn: false }) })
973
+ }
974
+ read()
975
+ const onFocus = () => { read() }
976
+ window.addEventListener('focus', onFocus)
977
+ return () => {
978
+ live = false
979
+ window.removeEventListener('focus', onFocus)
980
+ }
981
+ }, [enabled])
872
982
  if (!enabled) return null
873
- return React.createElement('div', { style: { padding: '2px 8px 4px', minWidth: 0 } },
983
+ const signedIn = status !== undefined && status.signedIn === true
984
+ const label = status === undefined
985
+ ? 'GitHub · checking…'
986
+ : signedIn ? `GitHub · ${String(status.login)}` : 'GitHub · signed out'
987
+ return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
874
988
  React.createElement('button', {
875
989
  type: 'button',
876
- 'data-polish-github': 'entry',
877
- title: 'Add a workspace from GitHub',
878
- 'aria-label': 'Add a workspace from GitHub',
879
- onClick: () => githubStore.set(true),
990
+ 'data-polish-github': 'status',
991
+ 'data-polish-github-state': signedIn ? 'in' : status === undefined ? 'checking' : 'out',
992
+ title: signedIn
993
+ ? 'Add a workspace from GitHub'
994
+ : 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
995
+ 'aria-label': label,
996
+ onClick: () => githubStore.open(),
997
+ style: FOOT_ROW_STYLE,
998
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
999
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1000
+ }, label))
1001
+ }
1002
+
1003
+ /** One row of the add-workspace source menu. */
1004
+ const SOURCE_ROW_STYLE = {
1005
+ display: 'flex',
1006
+ flexDirection: 'column',
1007
+ alignItems: 'flex-start',
1008
+ gap: '2px',
1009
+ width: '100%',
1010
+ padding: '8px 10px',
1011
+ border: 'none',
1012
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1013
+ background: 'transparent',
1014
+ color: 'inherit',
1015
+ font: 'inherit',
1016
+ textAlign: 'left',
1017
+ cursor: 'pointer',
1018
+ }
1019
+
1020
+ /**
1021
+ * The "+" add-workspace flow: choose where the workspace comes from.
1022
+ *
1023
+ * The Harness shows its own `Add workspace…` row only while this slot is
1024
+ * occupied, and a single-owner slot means this dialog stands in for the folder
1025
+ * picker. It therefore offers both sources: the machine (the Host's own OS
1026
+ * chooser when the composition serves the `native` capability, otherwise a
1027
+ * folder list driven by the browse primitives) and GitHub.
1028
+ *
1029
+ * @param props - the slot owner's `open`/`busy`/`onPicked`/`onCancel`/`onError`,
1030
+ * our lazy `configForms`, and the directory-picker calls.
1031
+ * @returns the flow, or null while it is closed or switched off.
1032
+ */
1033
+ function WorkspaceSourceFlow(props) {
1034
+ const enabled = useSwitch(props.configForms, 'workspaceSources', true)
1035
+ const [step, setStep] = React.useState('menu')
1036
+ const [listing, setListing] = React.useState(undefined)
1037
+ const [error, setError] = React.useState('')
1038
+ const [busy, setBusy] = React.useState(false)
1039
+
1040
+ const cancel = () => {
1041
+ if (typeof props.onCancel === 'function') props.onCancel()
1042
+ }
1043
+
1044
+ // Escape closes the flow. Declared with the other hooks, before the early
1045
+ // return, so the hook order stays fixed while the flow opens and closes.
1046
+ React.useEffect(() => {
1047
+ if (!enabled || props.open !== true) return undefined
1048
+ const onKey = event => { if (event.key === 'Escape') cancel() }
1049
+ document.addEventListener('keydown', onKey)
1050
+ return () => document.removeEventListener('keydown', onKey)
1051
+ }, [enabled, props.open])
1052
+
1053
+ if (!enabled || props.open !== true) return null
1054
+
1055
+ const fail = message => {
1056
+ setBusy(false)
1057
+ setError(message)
1058
+ }
1059
+ const messageOf = reason => (reason instanceof Error ? reason.message : String(reason))
1060
+
1061
+ const land = path => {
1062
+ setBusy(true)
1063
+ setError('')
1064
+ Promise.resolve()
1065
+ .then(() => props.onPicked(path))
1066
+ .catch(reason => fail(messageOf(reason)))
1067
+ }
1068
+
1069
+ const browse = path => {
1070
+ setBusy(true)
1071
+ setError('')
1072
+ Promise.resolve()
1073
+ .then(() => foldersCall({ action: 'ls', path }))
1074
+ .then(level => {
1075
+ setListing(level)
1076
+ setBusy(false)
1077
+ setStep('browse')
1078
+ })
1079
+ .catch(reason => fail(messageOf(reason)))
1080
+ }
1081
+
1082
+ /**
1083
+ * Hand folder choice to the Host.
1084
+ *
1085
+ * In the desktop app the preload exposes the real OS chooser as
1086
+ * `__DSH_DIRECTORY_PICKER__.pick()` — the same bridge the shipped native
1087
+ * picker uses — so the flow opens the Windows folder dialog and behaves like
1088
+ * the stock picker it stands in for. In a browser there is no such bridge, and
1089
+ * the composed picker's `pick` waits on a dialog that cannot open, so the flow
1090
+ * lists folders itself instead of hanging.
1091
+ */
1092
+ const chooseOnPc = () => {
1093
+ if (busy) return
1094
+ const bridge = typeof globalThis === 'undefined' ? undefined : globalThis.__DSH_DIRECTORY_PICKER__
1095
+ if (bridge !== undefined && typeof bridge.pick === 'function') {
1096
+ setBusy(true)
1097
+ setError('')
1098
+ Promise.resolve()
1099
+ .then(() => bridge.pick())
1100
+ .then(path => {
1101
+ if (typeof path === 'string' && path !== '') {
1102
+ land(path)
1103
+ return
1104
+ }
1105
+ setBusy(false)
1106
+ })
1107
+ .catch(reason => fail(messageOf(reason)))
1108
+ return
1109
+ }
1110
+ setBusy(true)
1111
+ setError('')
1112
+ Promise.resolve()
1113
+ .then(() => foldersCall({ action: 'roots' }))
1114
+ .then(payload => {
1115
+ const roots = Array.isArray(payload.roots) ? payload.roots : []
1116
+ setListing({
1117
+ path: '',
1118
+ parent: null,
1119
+ entries: roots.map(root => ({ name: root.label, path: root.path })),
1120
+ })
1121
+ setBusy(false)
1122
+ setStep('browse')
1123
+ })
1124
+ .catch(reason => fail(messageOf(reason)))
1125
+ }
1126
+
1127
+ const openGithub = () => {
1128
+ githubStore.open(path => props.onPicked(path))
1129
+ cancel()
1130
+ }
1131
+
1132
+ const rows = step === 'menu'
1133
+ ? [
1134
+ React.createElement('button', {
1135
+ key: 'pc',
1136
+ type: 'button',
1137
+ 'data-polish-source': 'machine',
1138
+ disabled: busy,
1139
+ onClick: chooseOnPc,
1140
+ style: SOURCE_ROW_STYLE,
1141
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1142
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1143
+ },
1144
+ React.createElement('span', { style: { fontWeight: 500 } }, 'On this PC'),
1145
+ React.createElement('span', { style: { opacity: 0.7 } }, 'Choose a folder on this machine')),
1146
+ React.createElement('button', {
1147
+ key: 'github',
1148
+ type: 'button',
1149
+ 'data-polish-source': 'github',
1150
+ disabled: busy,
1151
+ onClick: openGithub,
1152
+ style: SOURCE_ROW_STYLE,
1153
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1154
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1155
+ },
1156
+ React.createElement('span', { style: { fontWeight: 500 } }, 'GitHub repository…'),
1157
+ React.createElement('span', { style: { opacity: 0.7 } }, 'Clone one of your repositories as a workspace')),
1158
+ ]
1159
+ : [
1160
+ React.createElement('div', {
1161
+ key: 'head',
1162
+ style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 4px 6px', minWidth: 0 },
1163
+ },
1164
+ React.createElement('span', {
1165
+ style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
1166
+ title: listing !== undefined ? listing.path : '',
1167
+ }, listing === undefined || listing.path === '' ? 'This PC' : listing.path),
1168
+ listing === undefined || listing.path === ''
1169
+ ? null
1170
+ : React.createElement('button', {
1171
+ type: 'button',
1172
+ 'data-polish-pick': 'here',
1173
+ disabled: busy,
1174
+ onClick: () => { if (listing !== undefined) land(listing.path) },
1175
+ style: { ...FOOT_ROW_STYLE, color: 'var(--dsw-alias-label-primary, inherit)', background: 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1176
+ }, 'Use this folder')),
1177
+ ...(listing === undefined || listing.parent === null || listing.parent === undefined
1178
+ ? []
1179
+ : [React.createElement('button', {
1180
+ key: 'up',
1181
+ type: 'button',
1182
+ 'data-polish-folder': '..',
1183
+ disabled: busy,
1184
+ onClick: () => browse(listing.parent),
1185
+ style: SOURCE_ROW_STYLE,
1186
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1187
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1188
+ }, React.createElement('span', { title: listing.parent }, '..'))]),
1189
+ ...(listing === undefined
1190
+ ? []
1191
+ : listing.entries.map(entry => React.createElement('button', {
1192
+ key: entry.path,
1193
+ type: 'button',
1194
+ 'data-polish-folder': entry.name,
1195
+ disabled: busy,
1196
+ onClick: () => browse(entry.path),
1197
+ style: SOURCE_ROW_STYLE,
1198
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1199
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1200
+ }, React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: entry.path }, entry.name)))),
1201
+ ]
1202
+
1203
+ // The flow is rendered inside the Shell's add-workspace popover, which is
1204
+ // narrow and sits under the sidebar's own stacking order. A portal to the body
1205
+ // puts it above everything and keeps the rows readable at their own width.
1206
+ return ReactDOM.createPortal(
1207
+ React.createElement('div', {
1208
+ style: GITHUB_BACKDROP_STYLE,
1209
+ 'data-polish-flow-backdrop': step,
1210
+ onPointerDown: event => { if (event.target === event.currentTarget) cancel() },
1211
+ },
1212
+ React.createElement('div', {
1213
+ 'data-polish-flow': step,
1214
+ role: 'dialog',
1215
+ 'aria-label': 'Add a workspace',
880
1216
  style: {
881
1217
  display: 'flex',
882
- alignItems: 'center',
883
- gap: '6px',
884
- maxWidth: '100%',
885
- minWidth: 0,
886
- padding: '2px 6px',
887
- border: 'none',
888
- borderRadius: 'var(--dsw-radius-sm, 6px)',
889
- background: 'transparent',
890
- color: 'var(--dsw-alias-label-tertiary, inherit)',
891
- font: 'inherit',
1218
+ flexDirection: 'column',
1219
+ gap: '2px',
1220
+ width: 'min(360px, calc(100vw - 32px))',
1221
+ maxHeight: 'min(420px, calc(100vh - 96px))',
1222
+ padding: '6px',
1223
+ overflowY: 'auto',
1224
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
1225
+ ...PANEL_SURFACE,
1226
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
892
1227
  fontSize: '12px',
893
1228
  lineHeight: '18px',
894
- fontVariantNumeric: 'tabular-nums',
895
- whiteSpace: 'nowrap',
896
- overflow: 'hidden',
897
- textOverflow: 'ellipsis',
898
- cursor: 'pointer',
1229
+ color: 'var(--dsw-alias-label-secondary, inherit)',
899
1230
  },
900
- onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
901
- onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
902
- },
903
- React.createElement('svg', {
904
- width: 14,
905
- height: 14,
906
- viewBox: '0 0 16 16',
907
- fill: 'none',
908
- stroke: 'currentColor',
909
- strokeWidth: 1.2,
910
- strokeLinecap: 'round',
911
- strokeLinejoin: 'round',
912
- 'aria-hidden': 'true',
913
- style: { flex: 'none' },
914
1231
  },
915
- React.createElement('path', { d: 'M2.5 5.5h4l1.2 1.5h5.8v6.5h-11z' }),
916
- React.createElement('path', { d: 'M8 9.8v3.4M6.3 11.5h3.4' })),
917
- 'Add from GitHub'))
1232
+ error !== ''
1233
+ ? React.createElement('div', { style: { padding: '4px 6px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
1234
+ : null,
1235
+ ...rows,
1236
+ step === 'menu'
1237
+ ? null
1238
+ : React.createElement('button', {
1239
+ type: 'button',
1240
+ 'data-polish-pick': 'back',
1241
+ onClick: () => { setStep('menu'); setListing(undefined); setError('') },
1242
+ style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
1243
+ }, 'Back'),
1244
+ React.createElement('button', {
1245
+ type: 'button',
1246
+ 'data-polish-pick': 'cancel',
1247
+ onClick: cancel,
1248
+ style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
1249
+ }, 'Cancel'))),
1250
+ document.body)
918
1251
  }
919
1252
 
920
1253
  return {
@@ -995,12 +1328,29 @@ return {
995
1328
  id: 'polish-github',
996
1329
  inject: () => ({ configForms, createWorkspace }),
997
1330
  }, GitHubPanel))
1331
+ // The footer keeps the sign-in state, not an add button: adding a
1332
+ // workspace belongs in the "+" menu, where the Shell says it does.
998
1333
  githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
999
1334
  name: 'sidebar.footer.action',
1000
- id: 'polish-github-entry',
1335
+ id: 'polish-github-status',
1001
1336
  order: 4,
1002
1337
  inject: () => ({ configForms }),
1003
- }, GithubEntry))
1338
+ }, GithubStatus))
1004
1339
  })
1340
+ // The "+" menu's `Add workspace…` row exists only while a plugin occupies
1341
+ // this single-owner hole, so occupying it means standing in for the folder
1342
+ // picker: the flow offers the machine and GitHub as sources. It needs no
1343
+ // Remote injection: the folder list and the clone both come from this
1344
+ // bundle's own Host routes, which is what makes the flow behave the same in
1345
+ // the browser and in the app.
1346
+ ctx.slots.inject('sidebar.workspaces.directoryFlow', () => ctx.slots.register({
1347
+ name: 'sidebar.workspaces.directoryFlow',
1348
+ // A single slot clashes only at the exact priority; another priority
1349
+ // shadows it and the LOWEST priority renders. The shipped pickers
1350
+ // register at 0, so -1 is what puts this flow in front of them without
1351
+ // making their own activation throw.
1352
+ priority: -1,
1353
+ inject: () => ({ configForms }),
1354
+ }, WorkspaceSourceFlow))
1005
1355
  },
1006
1356
  }
@@ -24,6 +24,12 @@
24
24
  "label": "Add a workspace from GitHub",
25
25
  "description": "Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.",
26
26
  "defaultOn": true
27
+ },
28
+ {
29
+ "key": "workspaceSources",
30
+ "label": "Add a workspace from this PC or GitHub",
31
+ "description": "Put an On this PC / GitHub repository source menu behind the Workspaces + button.",
32
+ "defaultOn": true
27
33
  }
28
34
  ]
29
35
  }
@@ -31,16 +31,37 @@ server rejects a bearer token for clone, which is why the header carries
31
31
  `x-access-token` basic auth. Every line of git output is scrubbed of the token
32
32
  before it can reach the browser, and the route answers `cache-control: no-store`.
33
33
 
34
- ## Why the entry point is the sidebar foot
35
-
36
- The "+" menu's **Add workspace** entry only appears when something occupies
37
- `sidebar.workspaces.directoryFlow`, and that slot is `kind: 'single'`: it already
38
- holds the folder picker, so occupying it would mean re-implementing folder
39
- choosing (browse *and* the native desktop picker) just to add a second source.
40
- Rather than replace the shipped picker, the feature adds its own entry in the
41
- sidebar foot next to it. If a multi-source workspace socket ever lands in the
42
- harness, only the registration moves; the route, the clone rules and the tests
43
- stay as they are.
34
+ ## How the entry point works
35
+
36
+ The "+" menu's **Add workspace…** entry only appears when something occupies
37
+ `sidebar.workspaces.directoryFlow`, and that slot is `kind: 'single'` — whoever
38
+ takes it stands in for the folder picker. This bundle takes it and offers the
39
+ two sources the owner asked for:
40
+
41
+ - **On this PC** — the OS folder dialog. The desktop preload exposes it as
42
+ `window.__DSH_DIRECTORY_PICKER__.pick()`, the same bridge the shipped native
43
+ picker uses, so the flow calls it directly and behaves exactly like the picker
44
+ it stands in for. Where that bridge is absent (the web build) the flow falls
45
+ back to a folder browser driven by this bundle's own route
46
+ (`api/polish.folders`, actions `roots` and `ls`): roots, a `..` row for going
47
+ back, directories only, hidden and system entries filtered out, and **Use this
48
+ folder** hands the current path to the flow's `onPicked`.
49
+ - **GitHub repository…** — the dialog described above. It opens on behalf of the
50
+ flow, so a finished clone hands its path back through the flow's `onPicked`,
51
+ and the Shell creates the workspace exactly as it does for a picked folder.
52
+
53
+ ### Why the fallback exists at all
54
+
55
+ A composed directory picker serves exactly one capability: `browse` answers
56
+ `list` and `createDirectory`, `native` answers `pick`. The web profile composes
57
+ the picker whose chooser cannot open there, so `directoryPicker.pick()` waits on
58
+ a dialog that never appears — an earlier build of this flow did exactly that and
59
+ the row spun forever. The preload bridge is the reliable door in the app; the
60
+ listing is what a browser gets instead of a spinner.
61
+
62
+ The tray of the foot keeps the sign-in state (`GitHub · <login>`, or
63
+ `GitHub · signed out`) next to the balance, so the account is visible without
64
+ opening a dialog; clicking it opens the repository dialog directly.
44
65
 
45
66
  ## Not used from the AryaAI-era plugin
46
67