@crowdedkingdoms/crowdyjs 11.1.0 → 12.1.0

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.
Files changed (108) hide show
  1. package/MIGRATION.md +152 -0
  2. package/README.md +262 -0
  3. package/dist/crowdy-agent/browser-dispatcher.d.ts +41 -0
  4. package/dist/crowdy-agent/browser-dispatcher.d.ts.map +1 -0
  5. package/dist/crowdy-agent/browser-dispatcher.js +264 -0
  6. package/dist/crowdy-agent/controller.d.ts +144 -0
  7. package/dist/crowdy-agent/controller.d.ts.map +1 -0
  8. package/dist/crowdy-agent/controller.js +1239 -0
  9. package/dist/crowdy-agent/errors.d.ts +37 -0
  10. package/dist/crowdy-agent/errors.d.ts.map +1 -0
  11. package/dist/crowdy-agent/errors.js +107 -0
  12. package/dist/crowdy-agent/graphql-transport.d.ts +154 -0
  13. package/dist/crowdy-agent/graphql-transport.d.ts.map +1 -0
  14. package/dist/crowdy-agent/graphql-transport.js +854 -0
  15. package/dist/crowdy-agent/index.d.ts +16 -0
  16. package/dist/crowdy-agent/index.d.ts.map +1 -0
  17. package/dist/crowdy-agent/index.js +15 -0
  18. package/dist/crowdy-agent/registry.d.ts +29 -0
  19. package/dist/crowdy-agent/registry.d.ts.map +1 -0
  20. package/dist/crowdy-agent/registry.js +281 -0
  21. package/dist/crowdy-agent/schema.d.ts +72 -0
  22. package/dist/crowdy-agent/schema.d.ts.map +1 -0
  23. package/dist/crowdy-agent/schema.js +467 -0
  24. package/dist/crowdy-agent/studio-tools.d.ts +15 -0
  25. package/dist/crowdy-agent/studio-tools.d.ts.map +1 -0
  26. package/dist/crowdy-agent/studio-tools.js +280 -0
  27. package/dist/crowdy-agent/tool-descriptors.d.ts +4 -0
  28. package/dist/crowdy-agent/tool-descriptors.d.ts.map +1 -0
  29. package/dist/crowdy-agent/tool-descriptors.js +1049 -0
  30. package/dist/crowdy-agent/transport.d.ts +152 -0
  31. package/dist/crowdy-agent/transport.d.ts.map +1 -0
  32. package/dist/crowdy-agent/transport.js +27 -0
  33. package/dist/crowdy-agent/types.d.ts +328 -0
  34. package/dist/crowdy-agent/types.d.ts.map +1 -0
  35. package/dist/crowdy-agent/types.js +37 -0
  36. package/dist/crowdy-client.d.ts +3 -0
  37. package/dist/crowdy-client.d.ts.map +1 -1
  38. package/dist/crowdy-client.js +6 -0
  39. package/dist/crowdy-studio/agent-dom-shell.d.ts +68 -0
  40. package/dist/crowdy-studio/agent-dom-shell.d.ts.map +1 -0
  41. package/dist/crowdy-studio/agent-dom-shell.js +418 -0
  42. package/dist/crowdy-studio/controller.d.ts +74 -4
  43. package/dist/crowdy-studio/controller.d.ts.map +1 -1
  44. package/dist/crowdy-studio/controller.js +450 -17
  45. package/dist/crowdy-studio/dom-shell.d.ts +50 -4
  46. package/dist/crowdy-studio/dom-shell.d.ts.map +1 -1
  47. package/dist/crowdy-studio/dom-shell.js +554 -123
  48. package/dist/crowdy-studio/embed/dock.d.ts +56 -0
  49. package/dist/crowdy-studio/embed/dock.d.ts.map +1 -0
  50. package/dist/crowdy-studio/embed/dock.js +231 -0
  51. package/dist/crowdy-studio/embed/embed-styles.d.ts +12 -0
  52. package/dist/crowdy-studio/embed/embed-styles.d.ts.map +1 -0
  53. package/dist/crowdy-studio/embed/embed-styles.js +549 -0
  54. package/dist/crowdy-studio/embed/hud-layer.d.ts +40 -0
  55. package/dist/crowdy-studio/embed/hud-layer.d.ts.map +1 -0
  56. package/dist/crowdy-studio/embed/hud-layer.js +100 -0
  57. package/dist/crowdy-studio/embed/index.d.ts +5 -0
  58. package/dist/crowdy-studio/embed/index.d.ts.map +1 -0
  59. package/dist/crowdy-studio/embed/index.js +4 -0
  60. package/dist/crowdy-studio/embed/panel.d.ts +162 -0
  61. package/dist/crowdy-studio/embed/panel.d.ts.map +1 -0
  62. package/dist/crowdy-studio/embed/panel.js +642 -0
  63. package/dist/crowdy-studio/index.d.ts +9 -3
  64. package/dist/crowdy-studio/index.d.ts.map +1 -1
  65. package/dist/crowdy-studio/index.js +6 -0
  66. package/dist/crowdy-studio/layout.d.ts +50 -0
  67. package/dist/crowdy-studio/layout.d.ts.map +1 -0
  68. package/dist/crowdy-studio/layout.js +155 -0
  69. package/dist/crowdy-studio/models.d.ts +67 -0
  70. package/dist/crowdy-studio/models.d.ts.map +1 -1
  71. package/dist/crowdy-studio/mount.d.ts +15 -0
  72. package/dist/crowdy-studio/mount.d.ts.map +1 -1
  73. package/dist/crowdy-studio/mount.js +132 -3
  74. package/dist/crowdy-studio/splitter.d.ts +33 -0
  75. package/dist/crowdy-studio/splitter.d.ts.map +1 -0
  76. package/dist/crowdy-studio/splitter.js +133 -0
  77. package/dist/crowdy-studio/styles.d.ts +1 -1
  78. package/dist/crowdy-studio/styles.d.ts.map +1 -1
  79. package/dist/crowdy-studio/styles.js +128 -11
  80. package/dist/generated/graphql.d.ts +3753 -1
  81. package/dist/generated/graphql.d.ts.map +1 -1
  82. package/dist/generated/graphql.js +296 -0
  83. package/dist/index.d.ts +1 -1
  84. package/dist/index.js +1 -1
  85. package/dist/kit/npcs.d.ts.map +1 -1
  86. package/dist/kit/social.d.ts.map +1 -1
  87. package/dist/player-host/control-banner.d.ts +40 -0
  88. package/dist/player-host/control-banner.d.ts.map +1 -0
  89. package/dist/player-host/control-banner.js +252 -0
  90. package/dist/player-host/control-gate.d.ts +77 -0
  91. package/dist/player-host/control-gate.d.ts.map +1 -0
  92. package/dist/player-host/control-gate.js +197 -0
  93. package/dist/player-host/index.d.ts +7 -0
  94. package/dist/player-host/index.d.ts.map +1 -0
  95. package/dist/player-host/index.js +5 -0
  96. package/dist/player-host/lease-manager.d.ts +66 -0
  97. package/dist/player-host/lease-manager.d.ts.map +1 -0
  98. package/dist/player-host/lease-manager.js +428 -0
  99. package/dist/player-host/schemas.d.ts +9 -0
  100. package/dist/player-host/schemas.d.ts.map +1 -0
  101. package/dist/player-host/schemas.js +347 -0
  102. package/dist/player-host/tools.d.ts +13 -0
  103. package/dist/player-host/tools.d.ts.map +1 -0
  104. package/dist/player-host/tools.js +79 -0
  105. package/dist/player-host/types.d.ts +203 -0
  106. package/dist/player-host/types.d.ts.map +1 -0
  107. package/dist/player-host/types.js +1 -0
  108. package/package.json +17 -2
@@ -1,36 +1,69 @@
1
+ import { CrowdyStudioAgentDomShell, } from './agent-dom-shell.js';
2
+ import { StudioLayoutController, studioPaneSizeRange, } from './layout.js';
3
+ import { createPaneSplitter } from './splitter.js';
1
4
  import { projectTargets, } from './models.js';
2
5
  import { CROWDY_STUDIO_STYLES } from './styles.js';
3
- /** Modular DOM shell; editor implementations mount into `editorHost`. */
6
+ const FAILURE_PHASES = new Set([
7
+ 'COMPILE_FAILED',
8
+ 'ERROR',
9
+ 'PARTIAL_FAILURE',
10
+ ]);
11
+ /**
12
+ * Modular DOM shell; editor implementations mount into `editorHost`.
13
+ *
14
+ * Layout is user directed: the editor is always visible while the explorer,
15
+ * project settings, bottom panel, and agent dock are toggled from the
16
+ * activity rail, resized with accessible splitters, and persisted through
17
+ * {@link StudioLayoutController}.
18
+ */
4
19
  export class CrowdyStudioDomShell {
5
- constructor(host, controller) {
20
+ constructor(host, controller, agentController, agentOptions = {}) {
6
21
  this.controller = controller;
7
22
  this.panelButtons = new Map();
8
23
  this.panels = new Map();
24
+ this.railButtons = new Map();
25
+ this.splitters = new Map();
9
26
  this.activePanel = 'problems';
27
+ this.explorerForm = null;
28
+ this.lastState = null;
29
+ this.lastPhase = 'IDLE';
10
30
  this.disposed = false;
11
31
  const style = document.createElement('style');
12
32
  style.textContent = CROWDY_STUDIO_STYLES;
13
33
  this.root = element('div', 'ck-crowdy-studio');
14
34
  this.root.append(style);
35
+ this.layout = new StudioLayoutController();
36
+ // ----- Top bar -----------------------------------------------------
15
37
  const toolbar = element('div', 'ck-crowdy-studio-toolbar');
16
38
  const projectTools = element('div', 'ck-crowdy-studio-project-tools');
17
- this.projectSelect = document.createElement('select');
18
- this.projectSelect.setAttribute('aria-label', 'Current project');
19
- const newButton = button('New project');
20
- this.saveBadge = element('span', 'ck-crowdy-studio-save');
39
+ this.projectMenu = this.createMenu('No projects yet', {
40
+ buttonClass: 'ck-crowdy-studio-project-button',
41
+ ariaLabel: 'Current project',
42
+ });
43
+ this.projectMenuList = this.projectMenu.menu;
44
+ this.saveMenu = this.createMenu('Saved', {
45
+ buttonClass: 'ck-crowdy-studio-save',
46
+ ariaLabel: 'Save status',
47
+ });
48
+ this.saveMessageText = element('p', 'ck-crowdy-studio-save-message');
21
49
  this.saveAction = button('Retry save');
22
50
  this.remoteAction = button('Use cloud version');
23
- this.saveAction.hidden = true;
24
- this.remoteAction.hidden = true;
25
- projectTools.append(this.projectSelect, newButton, this.saveBadge, this.saveAction, this.remoteAction);
51
+ this.saveActions = element('div', 'ck-crowdy-studio-save-actions');
52
+ this.saveActions.append(this.saveAction, this.remoteAction);
53
+ this.saveMenu.menu.append(this.saveMessageText, this.saveActions);
54
+ projectTools.append(this.projectMenu.wrap, this.saveMenu.wrap);
26
55
  const runtimeTools = element('div', 'ck-crowdy-studio-runtime-tools');
27
56
  this.testAction = button('Test draft');
28
- this.deployAction = button('Deploy live');
29
- const stop = button('Stop project');
30
- this.runtimeStatus = element('span', 'ck-crowdy-studio-status');
31
- this.budgetStatus = element('span', 'ck-crowdy-studio-status');
32
- runtimeTools.append(this.testAction, this.deployAction, stop, this.runtimeStatus, this.budgetStatus);
57
+ this.testAction.className = 'ck-crowdy-studio-primary';
58
+ this.runMenu = this.createMenu('Run ', {
59
+ ariaLabel: 'More run actions',
60
+ });
61
+ this.deployAction = menuItem('Deploy live');
62
+ this.stopAction = menuItem('Stop project');
63
+ this.runMenu.menu.append(this.deployAction, this.stopAction);
64
+ runtimeTools.append(this.testAction, this.runMenu.wrap);
33
65
  toolbar.append(projectTools, runtimeTools);
66
+ // ----- New-project popover -----------------------------------------
34
67
  this.newForm = document.createElement('form');
35
68
  this.newForm.className = 'ck-crowdy-studio-new';
36
69
  this.newForm.dataset.open = 'false';
@@ -56,13 +89,34 @@ export class CrowdyStudioDomShell {
56
89
  const create = button('Create project', 'submit');
57
90
  const cancel = button('Cancel');
58
91
  this.newForm.append(this.newName, this.newKind, create, cancel);
59
- const main = element('div', 'ck-crowdy-studio-main');
92
+ // ----- Workspace: rail + panes + editor -----------------------------
93
+ this.workspace = element('div', 'ck-crowdy-studio-workspace');
94
+ const rail = element('nav', 'ck-crowdy-studio-rail');
95
+ rail.setAttribute('aria-label', 'Studio panes');
96
+ rail.append(this.railButton('explorer', 'Files', 'Show or hide project files'), this.railButton('settings', 'Settings', 'Show or hide project settings'), this.railButton('bottom', 'Console', 'Show or hide the console panel'));
60
97
  this.explorer = element('aside', 'ck-crowdy-studio-explorer');
61
- const editorColumn = element('section', 'ck-crowdy-studio-editor-column');
98
+ this.explorer.setAttribute('aria-label', 'Project files');
99
+ this.editorColumn = element('section', 'ck-crowdy-studio-editor-column');
62
100
  this.tabs = element('div', 'ck-crowdy-studio-tabs');
63
101
  this.editorHost = element('div', 'ck-crowdy-studio-editor');
64
- editorColumn.append(this.tabs, this.editorHost);
65
- const settings = element('aside', 'ck-crowdy-studio-settings');
102
+ this.bottom = element('section', 'ck-crowdy-studio-bottom');
103
+ const panelTabs = element('div', 'ck-crowdy-studio-panel-tabs');
104
+ this.problemsPanel = this.createPanel('problems', 'Problems', panelTabs);
105
+ this.buildPanel = this.createPanel('build', 'Build', panelTabs);
106
+ this.logsPanel = this.createPanel('logs', 'Logs', panelTabs);
107
+ this.runsPanel = this.createPanel('runs', 'Runs', panelTabs);
108
+ this.invokePanel = this.createPanel('invoke', 'Invoke', panelTabs);
109
+ const hidePanel = button('×');
110
+ hidePanel.className = 'ck-crowdy-studio-panel-hide';
111
+ hidePanel.setAttribute('aria-label', 'Hide the console panel');
112
+ hidePanel.addEventListener('click', () => this.layout.setVisible('bottom', false));
113
+ panelTabs.append(hidePanel);
114
+ const panelBody = element('div', 'ck-crowdy-studio-panel-body');
115
+ for (const panel of this.panels.values())
116
+ panelBody.append(panel);
117
+ this.bottom.append(panelTabs, panelBody);
118
+ this.editorColumn.append(this.tabs, this.editorHost, this.paneSplitter('bottom', 'horizontal', 'after', 'Resize the console panel'), this.bottom);
119
+ this.settings = element('aside', 'ck-crowdy-studio-settings');
66
120
  const settingsTitle = element('h3');
67
121
  settingsTitle.textContent = 'Project settings';
68
122
  this.settingsName = input('Project name');
@@ -74,40 +128,38 @@ export class CrowdyStudioDomShell {
74
128
  ['OPTIONAL', 'Optional companion'],
75
129
  ['REQUIRED', 'Require client companion'],
76
130
  ]);
77
- settings.append(settingsTitle, labeled('Name', this.settingsName), labeled('Description', this.settingsDescription), labeled('Server module', this.serverModuleName), labeled('Client module', this.clientModuleName), labeled('Pairing', this.pairing));
78
- main.append(this.explorer, editorColumn, settings);
79
- const bottom = element('section', 'ck-crowdy-studio-bottom');
80
- const panelTabs = element('div', 'ck-crowdy-studio-panel-tabs');
81
- this.problemsPanel = this.createPanel('problems', 'Problems', panelTabs);
82
- this.buildPanel = this.createPanel('build', 'Build', panelTabs);
83
- this.logsPanel = this.createPanel('logs', 'Logs', panelTabs);
84
- this.runsPanel = this.createPanel('runs', 'Runs', panelTabs);
85
- this.invokePanel = this.createPanel('invoke', 'Invoke', panelTabs);
86
- const panelBody = element('div');
87
- for (const panel of this.panels.values())
88
- panelBody.append(panel);
89
- bottom.append(panelTabs, panelBody);
90
- this.invokeExport = input('Export name');
91
- this.invokeExport.value = 'invoke';
92
- this.invokeParams = document.createElement('textarea');
93
- this.invokeParams.placeholder = '{"example": true}';
94
- this.invokeParams.setAttribute('aria-label', 'Invoke JSON parameters');
95
- const invokeButton = button('Invoke server export');
96
- this.invokeResult = document.createElement('pre');
97
- const invokeControls = element('div', 'ck-crowdy-studio-invoke');
98
- invokeControls.append(this.invokeExport, this.invokeParams, invokeButton);
99
- this.invokePanel.append(invokeControls, this.invokeResult);
100
- this.root.append(toolbar, this.newForm, main, bottom);
101
- host.appendChild(this.root);
102
- newButton.addEventListener('click', () => {
103
- this.newForm.dataset.open =
104
- this.newForm.dataset.open === 'true' ? 'false' : 'true';
105
- if (this.newForm.dataset.open === 'true')
106
- this.newName.focus();
107
- });
108
- cancel.addEventListener('click', () => {
109
- this.newForm.dataset.open = 'false';
131
+ this.settings.append(settingsTitle, labeled('Name', this.settingsName), labeled('Description', this.settingsDescription), labeled('Server module', this.serverModuleName), labeled('Client module', this.clientModuleName), labeled('Pairing', this.pairing));
132
+ this.workspace.append(rail, this.explorer, this.paneSplitter('explorer', 'vertical', 'before', 'Resize the file explorer'), this.editorColumn, this.paneSplitter('settings', 'vertical', 'after', 'Resize project settings'), this.settings);
133
+ // ----- Status bar ----------------------------------------------------
134
+ const statusbar = element('footer', 'ck-crowdy-studio-statusbar');
135
+ this.saveStatus = element('span', 'ck-crowdy-studio-status');
136
+ this.runtimeStatus = document.createElement('button');
137
+ this.runtimeStatus.type = 'button';
138
+ this.runtimeStatus.className =
139
+ 'ck-crowdy-studio-status ck-crowdy-studio-statusbar-runtime';
140
+ this.runtimeStatus.setAttribute('aria-label', 'Runtime status; opens the console panel');
141
+ this.runtimeStatus.addEventListener('click', () => {
142
+ this.layout.setVisible('bottom', true);
143
+ this.activatePanel(this.activePanel);
110
144
  });
145
+ this.budgetStatus = element('span', 'ck-crowdy-studio-status');
146
+ statusbar.append(this.saveStatus, this.runtimeStatus, this.budgetStatus);
147
+ // The popover is absolutely positioned against the toolbar so it opens
148
+ // right under the project menu button.
149
+ toolbar.append(this.newForm);
150
+ this.root.append(toolbar, this.workspace, statusbar);
151
+ host.appendChild(this.root);
152
+ // ----- Agent dock ----------------------------------------------------
153
+ if (agentController) {
154
+ rail.append(this.railButton('agent', 'Agent', 'Show or hide the Crowdy Agent dock'));
155
+ this.workspace.append(this.paneSplitter('agent', 'vertical', 'after', 'Resize the agent dock'));
156
+ this.agentShell = new CrowdyStudioAgentDomShell(this.workspace, agentController, { ...agentOptions, layout: agentOptions.layout ?? this.layout });
157
+ this.root.dataset.agent = 'true';
158
+ }
159
+ else {
160
+ this.agentShell = null;
161
+ }
162
+ // ----- Wiring ---------------------------------------------------------
111
163
  this.newForm.addEventListener('submit', (event) => {
112
164
  event.preventDefault();
113
165
  void this.run(async () => {
@@ -119,18 +171,32 @@ export class CrowdyStudioDomShell {
119
171
  this.newForm.dataset.open = 'false';
120
172
  });
121
173
  });
122
- this.projectSelect.addEventListener('change', () => {
123
- void this.run(() => this.controller.switchProject(this.projectSelect.value));
174
+ cancel.addEventListener('click', () => {
175
+ this.newForm.dataset.open = 'false';
176
+ });
177
+ this.testAction.addEventListener('click', () => {
178
+ this.revealPanel('build');
179
+ void this.run(() => this.controller.testDraft());
180
+ });
181
+ this.deployAction.addEventListener('click', () => {
182
+ this.runMenu.close();
183
+ this.revealPanel('build');
184
+ void this.run(() => this.controller.deployLive());
185
+ });
186
+ this.stopAction.addEventListener('click', () => {
187
+ this.runMenu.close();
188
+ void this.run(() => this.controller.stopProject());
124
189
  });
125
- this.testAction.addEventListener('click', () => void this.run(() => this.controller.testDraft()));
126
- this.deployAction.addEventListener('click', () => void this.run(() => this.controller.deployLive()));
127
- stop.addEventListener('click', () => void this.run(() => this.controller.stopProject()));
128
190
  this.saveAction.addEventListener('click', () => {
191
+ this.saveMenu.close();
129
192
  void this.run(() => this.controller.getState().saveState === 'CONFLICT'
130
193
  ? this.controller.overwriteConflict()
131
194
  : this.controller.retrySave());
132
195
  });
133
- this.remoteAction.addEventListener('click', () => void this.run(() => this.controller.acceptRemoteConflict()));
196
+ this.remoteAction.addEventListener('click', () => {
197
+ this.saveMenu.close();
198
+ void this.run(() => this.controller.acceptRemoteConflict());
199
+ });
134
200
  this.settingsName.addEventListener('change', () => this.controller.updateSettings({ name: this.settingsName.value }));
135
201
  this.settingsDescription.addEventListener('change', () => this.controller.updateSettings({
136
202
  description: this.settingsDescription.value,
@@ -142,15 +208,28 @@ export class CrowdyStudioDomShell {
142
208
  clientModuleName: this.clientModuleName.value,
143
209
  }));
144
210
  this.pairing.addEventListener('change', () => this.controller.setPairingPreference(this.pairing.value));
211
+ this.invokeExport = input('Export name');
212
+ this.invokeExport.value = 'invoke';
213
+ this.invokeParams = document.createElement('textarea');
214
+ this.invokeParams.placeholder = '{"example": true}';
215
+ this.invokeParams.setAttribute('aria-label', 'Invoke JSON parameters');
216
+ const invokeButton = button('Invoke server export');
217
+ this.invokeResult = document.createElement('pre');
218
+ const invokeControls = element('div', 'ck-crowdy-studio-invoke');
219
+ invokeControls.append(this.invokeExport, this.invokeParams, invokeButton);
220
+ this.invokePanel.append(invokeControls, this.invokeResult);
145
221
  invokeButton.addEventListener('click', () => {
146
222
  void this.run(() => this.controller.invoke(this.invokeExport.value, this.invokeParams.value));
147
223
  });
148
224
  this.controller.setSurfaceVisible('usage', true);
225
+ this.unsubscribeLayout = this.layout.subscribe((state) => this.applyLayout(state));
226
+ this.applyLayout(this.layout.getState());
149
227
  this.activatePanel('problems');
150
228
  }
151
229
  render(state) {
152
230
  if (this.disposed)
153
231
  return;
232
+ this.lastState = state;
154
233
  this.renderProjects(state);
155
234
  this.renderSaveState(state);
156
235
  this.renderExplorer(state);
@@ -168,14 +247,13 @@ export class CrowdyStudioDomShell {
168
247
  this.invokeResult.textContent = state.invokeResult
169
248
  ? formatInvokeResult(state.invokeResult)
170
249
  : '';
171
- this.runtimeStatus.textContent = [
172
- state.runtime.phase,
173
- state.runtime.target,
174
- state.runtime.message,
175
- ]
176
- .filter(Boolean)
177
- .join(' · ');
250
+ this.runtimeStatus.textContent =
251
+ [state.runtime.phase, state.runtime.target, state.runtime.message]
252
+ .filter(Boolean)
253
+ .join(' · ') || 'IDLE';
254
+ this.runtimeStatus.dataset.phase = state.runtime.phase;
178
255
  this.budgetStatus.textContent = budgetText(state);
256
+ this.autoRevealFailures(state);
179
257
  }
180
258
  dispose() {
181
259
  if (this.disposed)
@@ -184,8 +262,174 @@ export class CrowdyStudioDomShell {
184
262
  this.controller.setSurfaceVisible('usage', false);
185
263
  this.controller.setSurfaceVisible('logs', false);
186
264
  this.controller.setSurfaceVisible('runs', false);
265
+ this.unsubscribeLayout();
266
+ for (const splitter of this.splitters.values())
267
+ splitter.dispose();
268
+ this.projectMenu.dispose();
269
+ this.saveMenu.dispose();
270
+ this.runMenu.dispose();
271
+ this.agentShell?.dispose();
187
272
  this.root.remove();
188
273
  }
274
+ // ----- Layout ----------------------------------------------------------
275
+ railButton(pane, label, description) {
276
+ const control = button(label);
277
+ control.className = 'ck-crowdy-studio-rail-button';
278
+ control.dataset.pane = pane;
279
+ control.title = description;
280
+ control.setAttribute('aria-pressed', 'false');
281
+ control.addEventListener('click', () => {
282
+ const wasVisible = this.layout.isVisible(pane);
283
+ this.layout.toggle(pane);
284
+ if (!wasVisible)
285
+ this.collapseOverlappingPanes(pane);
286
+ });
287
+ this.railButtons.set(pane, control);
288
+ return control;
289
+ }
290
+ /**
291
+ * Narrow widths turn side panes into overlays (styles.ts container
292
+ * queries), where several open panes would stack invisibly on top of each
293
+ * other. Enforce the "single pane" rule there: opening one side pane
294
+ * closes the others that overlay at the current width.
295
+ */
296
+ collapseOverlappingPanes(opened) {
297
+ if (opened === 'bottom')
298
+ return;
299
+ const width = this.root.clientWidth;
300
+ if (!width)
301
+ return;
302
+ const overlaying = [];
303
+ if (width <= 900)
304
+ overlaying.push('settings');
305
+ if (width <= 760)
306
+ overlaying.push('agent');
307
+ if (width <= 620)
308
+ overlaying.push('explorer');
309
+ if (!overlaying.includes(opened))
310
+ return;
311
+ for (const pane of overlaying) {
312
+ if (pane !== opened && this.layout.isVisible(pane)) {
313
+ this.layout.setVisible(pane, false);
314
+ }
315
+ }
316
+ }
317
+ paneSplitter(pane, orientation, position, label) {
318
+ const splitter = createPaneSplitter({
319
+ orientation,
320
+ pane: position,
321
+ label,
322
+ range: () => studioPaneSizeRange(pane),
323
+ getSize: () => this.layout.paneSize(pane),
324
+ setSize: (size, commit) => this.layout.setSize(pane, size, commit),
325
+ });
326
+ this.splitters.set(pane, splitter);
327
+ return splitter.element;
328
+ }
329
+ applyLayout(state) {
330
+ const paneElements = {
331
+ explorer: this.explorer,
332
+ settings: this.settings,
333
+ bottom: this.bottom,
334
+ agent: this.agentShell?.root ?? null,
335
+ };
336
+ for (const pane of ['explorer', 'settings', 'bottom', 'agent']) {
337
+ const paneElement = paneElements[pane];
338
+ const visible = state.visible[pane] && paneElement !== null;
339
+ if (paneElement) {
340
+ paneElement.hidden = !visible;
341
+ if (pane === 'bottom') {
342
+ paneElement.style.height = `${state.sizes[pane]}px`;
343
+ }
344
+ else {
345
+ paneElement.style.width = `${state.sizes[pane]}px`;
346
+ }
347
+ }
348
+ const splitter = this.splitters.get(pane);
349
+ if (splitter) {
350
+ splitter.element.hidden = !visible;
351
+ splitter.refresh();
352
+ }
353
+ const railControl = this.railButtons.get(pane);
354
+ if (railControl) {
355
+ railControl.setAttribute('aria-pressed', String(visible));
356
+ railControl.dataset.active = String(visible);
357
+ }
358
+ }
359
+ this.syncPanelPolling();
360
+ }
361
+ revealPanel(name) {
362
+ this.layout.setVisible('bottom', true);
363
+ this.activatePanel(name);
364
+ }
365
+ autoRevealFailures(state) {
366
+ const phase = state.runtime.phase;
367
+ if (phase !== this.lastPhase && FAILURE_PHASES.has(phase)) {
368
+ const diagnostics = state.authoritativeDiagnostics.length + state.localDiagnostics.length;
369
+ this.revealPanel(diagnostics > 0 ? 'problems' : 'build');
370
+ }
371
+ this.lastPhase = phase;
372
+ }
373
+ // ----- Menus -----------------------------------------------------------
374
+ createMenu(label, options = {}) {
375
+ const wrap = element('div', 'ck-crowdy-studio-menu-wrap');
376
+ const control = button(label);
377
+ control.className = options.buttonClass
378
+ ? `ck-crowdy-studio-menu-button ${options.buttonClass}`
379
+ : 'ck-crowdy-studio-menu-button';
380
+ if (options.ariaLabel)
381
+ control.setAttribute('aria-label', options.ariaLabel);
382
+ control.setAttribute('aria-haspopup', 'true');
383
+ control.setAttribute('aria-expanded', 'false');
384
+ const menu = element('div', 'ck-crowdy-studio-menu');
385
+ menu.hidden = true;
386
+ menu.setAttribute('role', 'menu');
387
+ wrap.append(control, menu);
388
+ const outsideClick = (event) => {
389
+ if (!wrap.contains(event.target))
390
+ close();
391
+ };
392
+ const escape = (event) => {
393
+ if (event.key !== 'Escape')
394
+ return;
395
+ event.stopPropagation();
396
+ close();
397
+ control.focus();
398
+ };
399
+ const close = () => {
400
+ if (menu.hidden)
401
+ return;
402
+ menu.hidden = true;
403
+ control.setAttribute('aria-expanded', 'false');
404
+ document.removeEventListener('pointerdown', outsideClick, true);
405
+ wrap.removeEventListener('keydown', escape);
406
+ };
407
+ const open = () => {
408
+ if (!menu.hidden)
409
+ return;
410
+ menu.hidden = false;
411
+ control.setAttribute('aria-expanded', 'true');
412
+ document.addEventListener('pointerdown', outsideClick, true);
413
+ wrap.addEventListener('keydown', escape);
414
+ };
415
+ control.addEventListener('click', () => {
416
+ if (menu.hidden)
417
+ open();
418
+ else
419
+ close();
420
+ });
421
+ return {
422
+ button: control,
423
+ menu,
424
+ wrap,
425
+ close,
426
+ dispose() {
427
+ close();
428
+ wrap.remove();
429
+ },
430
+ };
431
+ }
432
+ // ----- Panels ------------------------------------------------------------
189
433
  createPanel(name, label, tabs) {
190
434
  const tab = button(label);
191
435
  tab.className = 'ck-crowdy-studio-panel-tab';
@@ -198,7 +442,6 @@ export class CrowdyStudioDomShell {
198
442
  return panel;
199
443
  }
200
444
  activatePanel(name) {
201
- const previous = this.activePanel;
202
445
  this.activePanel = name;
203
446
  for (const [key, buttonElement] of this.panelButtons) {
204
447
  buttonElement.dataset.active = String(key === name);
@@ -206,32 +449,43 @@ export class CrowdyStudioDomShell {
206
449
  for (const [key, panel] of this.panels) {
207
450
  panel.dataset.active = String(key === name);
208
451
  }
209
- const previousPoll = polledSurface(previous);
210
- const nextPoll = polledSurface(name);
211
- if (previousPoll && previousPoll !== nextPoll) {
212
- this.controller.setSurfaceVisible(previousPoll, false);
452
+ this.syncPanelPolling();
453
+ }
454
+ /** Poll logs/runs only while their panel is both selected and shown. */
455
+ syncPanelPolling() {
456
+ const bottomVisible = this.layout.isVisible('bottom');
457
+ for (const surface of ['logs', 'runs']) {
458
+ this.controller.setSurfaceVisible(surface, bottomVisible && this.activePanel === surface);
213
459
  }
214
- if (nextPoll)
215
- this.controller.setSurfaceVisible(nextPoll, true);
216
460
  }
461
+ // ----- Rendering -----------------------------------------------------------
217
462
  renderProjects(state) {
218
- const selected = state.project?.projectId ?? '';
219
- const options = state.projects.map((project) => {
220
- const option = document.createElement('option');
221
- option.value = project.projectId;
222
- option.textContent = `${project.name} · ${project.kind
223
- .toLowerCase()
224
- .replace('_', ' ')}`;
225
- return option;
463
+ const selected = state.project;
464
+ this.projectMenu.button.textContent = selected
465
+ ? `${selected.metadata.name} ▾`
466
+ : state.projects.length === 0
467
+ ? 'No projects yet ▾'
468
+ : 'Select project ▾';
469
+ const items = state.projects.map((project) => {
470
+ const item = menuItem(`${project.name} · ${project.kind.toLowerCase().replace('_', ' ')}`);
471
+ item.dataset.active = String(project.projectId === selected?.projectId);
472
+ item.addEventListener('click', () => {
473
+ this.projectMenu.close();
474
+ void this.run(() => this.controller.switchProject(project.projectId));
475
+ });
476
+ return item;
226
477
  });
227
- if (options.length === 0) {
228
- const empty = document.createElement('option');
229
- empty.value = '';
230
- empty.textContent = 'No projects yet';
231
- options.push(empty);
478
+ if (items.length === 0) {
479
+ items.push(empty('No projects yet.'));
232
480
  }
233
- this.projectSelect.replaceChildren(...options);
234
- this.projectSelect.value = selected;
481
+ const divider = element('div', 'ck-crowdy-studio-menu-divider');
482
+ const newProject = menuItem('New project…');
483
+ newProject.addEventListener('click', () => {
484
+ this.projectMenu.close();
485
+ this.newForm.dataset.open = 'true';
486
+ this.newName.focus();
487
+ });
488
+ this.projectMenuList.replaceChildren(...items, divider, newProject);
235
489
  }
236
490
  renderSaveState(state) {
237
491
  const label = {
@@ -240,16 +494,25 @@ export class CrowdyStudioDomShell {
240
494
  CONFLICT: 'Conflict',
241
495
  OFFLINE: 'Offline',
242
496
  }[state.saveState];
243
- this.saveBadge.dataset.state = state.saveState;
244
- this.saveBadge.textContent = state.saveMessage
497
+ this.saveMenu.button.dataset.state = state.saveState;
498
+ this.saveMenu.button.textContent = label;
499
+ this.saveStatus.textContent = state.saveMessage
245
500
  ? `${label}: ${state.saveMessage}`
246
501
  : label;
247
- this.saveAction.hidden = !['CONFLICT', 'OFFLINE'].includes(state.saveState);
502
+ this.saveMessageText.textContent = state.saveMessage
503
+ ? state.saveMessage
504
+ : state.saveState === 'SAVED'
505
+ ? 'Edits autosave to your private project.'
506
+ : label;
507
+ const attention = ['CONFLICT', 'OFFLINE'].includes(state.saveState);
508
+ this.saveAction.hidden = !attention;
248
509
  this.saveAction.textContent =
249
510
  state.saveState === 'CONFLICT' ? 'Keep my version' : 'Retry save';
250
511
  this.remoteAction.hidden = state.saveState !== 'CONFLICT';
512
+ this.saveMenu.button.dataset.attention = String(attention);
251
513
  }
252
514
  renderExplorer(state) {
515
+ const focused = focusedExplorerField(this.explorer);
253
516
  this.explorer.replaceChildren();
254
517
  if (!state.project) {
255
518
  this.explorer.append(empty('Create a project to start authoring.'));
@@ -264,6 +527,7 @@ export class CrowdyStudioDomShell {
264
527
  }))));
265
528
  }
266
529
  this.explorer.append(this.referenceSection('Personal library', state.personalLibraryFiles), this.referenceSection('Common files', state.commonFiles));
530
+ restoreExplorerFocus(this.explorer, focused);
267
531
  }
268
532
  projectSection(target, refs) {
269
533
  const section = element('section', 'ck-crowdy-studio-section');
@@ -274,13 +538,29 @@ export class CrowdyStudioDomShell {
274
538
  add.setAttribute('aria-label', `Add ${target} file`);
275
539
  add.disabled = !this.controller.canTarget(target, 'write');
276
540
  add.addEventListener('click', () => {
277
- const path = window.prompt(`New ${target} file path`, 'src/new.rs');
278
- if (!path)
279
- return;
280
- void this.run(() => Promise.resolve(this.controller.addFile(target, path)));
541
+ this.explorerForm =
542
+ this.explorerForm?.kind === 'add' && this.explorerForm.target === target
543
+ ? null
544
+ : { kind: 'add', target, value: 'src/new.rs' };
545
+ this.rerenderExplorer();
281
546
  });
282
547
  header.append(title, add);
283
548
  section.append(header);
549
+ if (this.explorerForm?.kind === 'add' && this.explorerForm.target === target) {
550
+ section.append(this.inlineForm({
551
+ value: this.explorerForm.value,
552
+ placeholder: `New ${target} file path`,
553
+ submitLabel: 'Add file',
554
+ onInput: (value) => {
555
+ if (this.explorerForm?.kind === 'add')
556
+ this.explorerForm.value = value;
557
+ },
558
+ onSubmit: (value) => {
559
+ this.explorerForm = null;
560
+ void this.run(() => Promise.resolve(this.controller.addFile(target, value)));
561
+ },
562
+ }));
563
+ }
284
564
  for (const ref of refs) {
285
565
  section.append(this.projectFileRow(ref));
286
566
  }
@@ -289,38 +569,97 @@ export class CrowdyStudioDomShell {
289
569
  return section;
290
570
  }
291
571
  projectFileRow(ref) {
572
+ const form = this.explorerForm;
573
+ if (form &&
574
+ form.kind !== 'add' &&
575
+ form.kind !== 'import' &&
576
+ form.target === ref.target &&
577
+ form.path === ref.path) {
578
+ return this.projectFileFormRow(ref, form);
579
+ }
292
580
  const row = element('div', 'ck-crowdy-studio-file');
293
581
  const open = button(ref.path);
294
582
  open.title = `${ref.target}:${ref.path}`;
295
583
  open.addEventListener('click', () => this.controller.openFile(ref));
296
- const rename = button('');
297
- rename.setAttribute('aria-label', `Rename ${ref.path}`);
298
- rename.addEventListener('click', () => {
299
- const next = window.prompt('Rename file', ref.path);
300
- if (!next || !ref.target)
301
- return;
302
- void this.run(() => Promise.resolve(this.controller.renameFile(ref.target, ref.path, next)));
584
+ const actions = this.createMenu('', {
585
+ ariaLabel: `Actions for ${ref.path}`,
586
+ buttonClass: 'ck-crowdy-studio-file-action',
303
587
  });
304
- const remove = button('×');
305
- remove.setAttribute('aria-label', `Delete ${ref.path}`);
306
- remove.addEventListener('click', () => {
307
- if (!ref.target ||
308
- !window.confirm(`Delete ${ref.target}:${ref.path}?`)) {
588
+ const library = menuItem('Save to library');
589
+ library.addEventListener('click', () => {
590
+ actions.close();
591
+ if (!ref.target)
309
592
  return;
310
- }
311
- void this.run(() => Promise.resolve(this.controller.deleteFile(ref.target, ref.path)));
593
+ this.explorerForm = {
594
+ kind: 'library',
595
+ target: ref.target,
596
+ path: ref.path,
597
+ value: ref.path.split('/').at(-1) ?? ref.path,
598
+ };
599
+ this.rerenderExplorer();
312
600
  });
313
- const library = button('Library');
314
- library.setAttribute('aria-label', `Save ${ref.path} to My Library`);
315
- library.addEventListener('click', () => {
601
+ const rename = menuItem('Rename');
602
+ rename.addEventListener('click', () => {
603
+ actions.close();
316
604
  if (!ref.target)
317
605
  return;
318
- const title = window.prompt('Library file title', ref.path.split('/').at(-1) ?? ref.path);
319
- if (title == null)
606
+ this.explorerForm = {
607
+ kind: 'rename',
608
+ target: ref.target,
609
+ path: ref.path,
610
+ value: ref.path,
611
+ };
612
+ this.rerenderExplorer();
613
+ });
614
+ const remove = menuItem('Delete');
615
+ remove.addEventListener('click', () => {
616
+ actions.close();
617
+ if (!ref.target)
320
618
  return;
321
- void this.run(() => this.controller.saveProjectFileToLibrary(ref.target, ref.path, title));
619
+ this.explorerForm = { kind: 'delete', target: ref.target, path: ref.path };
620
+ this.rerenderExplorer();
322
621
  });
323
- row.append(open, library, rename, remove);
622
+ actions.menu.append(library, rename, remove);
623
+ row.append(open, actions.wrap);
624
+ return row;
625
+ }
626
+ projectFileFormRow(ref, form) {
627
+ const row = element('div', 'ck-crowdy-studio-file-form-row');
628
+ if (form.kind === 'delete') {
629
+ const text = document.createElement('span');
630
+ text.textContent = `Delete ${ref.target}:${ref.path}?`;
631
+ const confirm = button('Delete');
632
+ confirm.addEventListener('click', () => {
633
+ this.explorerForm = null;
634
+ void this.run(() => Promise.resolve(this.controller.deleteFile(form.target, form.path)));
635
+ });
636
+ const cancel = button('Cancel');
637
+ cancel.addEventListener('click', () => {
638
+ this.explorerForm = null;
639
+ this.rerenderExplorer();
640
+ });
641
+ row.append(text, confirm, cancel);
642
+ return row;
643
+ }
644
+ row.append(this.inlineForm({
645
+ value: form.value,
646
+ placeholder: form.kind === 'rename' ? 'New file path' : 'Library file title',
647
+ submitLabel: form.kind === 'rename' ? 'Rename' : 'Save to library',
648
+ onInput: (value) => {
649
+ if (this.explorerForm && this.explorerForm.kind === form.kind) {
650
+ this.explorerForm.value = value;
651
+ }
652
+ },
653
+ onSubmit: (value) => {
654
+ this.explorerForm = null;
655
+ if (form.kind === 'rename') {
656
+ void this.run(() => Promise.resolve(this.controller.renameFile(form.target, form.path, value)));
657
+ }
658
+ else {
659
+ void this.run(() => this.controller.saveProjectFileToLibrary(form.target, form.path, value));
660
+ }
661
+ },
662
+ }));
324
663
  return row;
325
664
  }
326
665
  referenceSection(titleText, files) {
@@ -341,18 +680,78 @@ export class CrowdyStudioDomShell {
341
680
  const add = button('Add');
342
681
  add.setAttribute('aria-label', `Add ${file.title} to project`);
343
682
  add.addEventListener('click', () => {
344
- const destination = window.prompt('Destination project path', file.path);
345
- if (!destination)
346
- return;
347
- void this.run(() => this.controller.importReferenceFile(file, destination));
683
+ this.explorerForm =
684
+ this.explorerForm?.kind === 'import' &&
685
+ this.explorerForm.referenceId === file.id
686
+ ? null
687
+ : { kind: 'import', referenceId: file.id, value: file.path };
688
+ this.rerenderExplorer();
348
689
  });
349
690
  row.append(open, add);
350
691
  section.append(row);
692
+ if (this.explorerForm?.kind === 'import' &&
693
+ this.explorerForm.referenceId === file.id) {
694
+ section.append(this.inlineForm({
695
+ value: this.explorerForm.value,
696
+ placeholder: 'Destination project path',
697
+ submitLabel: 'Add to project',
698
+ onInput: (value) => {
699
+ if (this.explorerForm?.kind === 'import') {
700
+ this.explorerForm.value = value;
701
+ }
702
+ },
703
+ onSubmit: (value) => {
704
+ this.explorerForm = null;
705
+ void this.run(() => this.controller.importReferenceFile(file, value));
706
+ },
707
+ }));
708
+ }
351
709
  }
352
710
  if (files.length === 0)
353
711
  section.append(empty('No files'));
354
712
  return section;
355
713
  }
714
+ inlineForm(options) {
715
+ const form = document.createElement('form');
716
+ form.className = 'ck-crowdy-studio-inline-form';
717
+ const field = document.createElement('input');
718
+ field.value = options.value;
719
+ field.placeholder = options.placeholder;
720
+ field.setAttribute('aria-label', options.placeholder);
721
+ field.dataset.explorerField = 'true';
722
+ field.addEventListener('input', () => options.onInput(field.value));
723
+ const submit = button(options.submitLabel, 'submit');
724
+ const cancel = button('Cancel');
725
+ cancel.addEventListener('click', () => {
726
+ this.explorerForm = null;
727
+ this.rerenderExplorer();
728
+ });
729
+ form.append(field, submit, cancel);
730
+ form.addEventListener('submit', (event) => {
731
+ event.preventDefault();
732
+ const value = field.value.trim();
733
+ if (!value)
734
+ return;
735
+ options.onSubmit(value);
736
+ });
737
+ queueMicrotask(() => {
738
+ try {
739
+ if (field.isConnected &&
740
+ !this.explorer.contains(document.activeElement)) {
741
+ field.focus();
742
+ field.select();
743
+ }
744
+ }
745
+ catch {
746
+ // Focus management is an enhancement only.
747
+ }
748
+ });
749
+ return form;
750
+ }
751
+ rerenderExplorer() {
752
+ if (this.lastState)
753
+ this.renderExplorer(this.lastState);
754
+ }
356
755
  renderTabs(state) {
357
756
  this.tabs.replaceChildren();
358
757
  for (const ref of state.openFiles) {
@@ -401,6 +800,11 @@ export class CrowdyStudioDomShell {
401
800
  ...state.authoritativeDiagnostics,
402
801
  ...state.localDiagnostics,
403
802
  ];
803
+ const problemsTab = this.panelButtons.get('problems');
804
+ if (problemsTab) {
805
+ problemsTab.textContent =
806
+ diagnostics.length > 0 ? `Problems (${diagnostics.length})` : 'Problems';
807
+ }
404
808
  if (diagnostics.length === 0) {
405
809
  this.problemsPanel.append(empty('No problems.'));
406
810
  return;
@@ -452,11 +856,6 @@ export class CrowdyStudioDomShell {
452
856
  }
453
857
  }
454
858
  }
455
- function polledSurface(panel) {
456
- if (panel === 'logs' || panel === 'runs')
457
- return panel;
458
- return null;
459
- }
460
859
  function budgetText(state) {
461
860
  const usage = state.usage
462
861
  ? `units ${state.usage.hourUnitsUsed}/${state.usage.unitsPerHour ?? '∞'} · compiles ${state.usage.compilesThisHour}/${state.usage.maxCompilesPerHour}`
@@ -481,6 +880,32 @@ function sameRef(a, b) {
481
880
  a.path === b.path &&
482
881
  a.referenceId === b.referenceId);
483
882
  }
883
+ function focusedExplorerField(explorer) {
884
+ if (typeof HTMLInputElement === 'undefined')
885
+ return null;
886
+ const active = document.activeElement;
887
+ if (active instanceof HTMLInputElement &&
888
+ explorer.contains(active) &&
889
+ active.dataset.explorerField === 'true') {
890
+ return active.value;
891
+ }
892
+ return null;
893
+ }
894
+ function restoreExplorerFocus(explorer, value) {
895
+ if (value === null)
896
+ return;
897
+ const field = explorer.querySelector('input[data-explorer-field="true"]');
898
+ if (!field)
899
+ return;
900
+ field.focus();
901
+ const end = field.value.length;
902
+ try {
903
+ field.setSelectionRange(end, end);
904
+ }
905
+ catch {
906
+ // Selection APIs are an enhancement only.
907
+ }
908
+ }
484
909
  function element(tag, className) {
485
910
  const value = document.createElement(tag);
486
911
  if (className)
@@ -493,6 +918,12 @@ function button(label, type = 'button') {
493
918
  value.textContent = label;
494
919
  return value;
495
920
  }
921
+ function menuItem(label) {
922
+ const value = button(label);
923
+ value.className = 'ck-crowdy-studio-menu-item';
924
+ value.setAttribute('role', 'menuitem');
925
+ return value;
926
+ }
496
927
  function input(placeholder) {
497
928
  const value = document.createElement('input');
498
929
  value.placeholder = placeholder;