workspaces 0.1.7 → 0.1.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d9d7e03d386bd8949baf124281f3ac26eb2b49f7fb109a97a8f0a4fb16263fa
4
- data.tar.gz: 76ab8e2adfeb5a5daea4526a41d6d33991076000bd4bed0f621df6355511341a
3
+ metadata.gz: c580535eeb20692a3454144150752141b76eeed504ca7f13d59f2ee498e5e8eb
4
+ data.tar.gz: dc5fbe433b8ffd2c485bdbf91bcb2aae72bf4010926f4c0106361cc5a1369ce6
5
5
  SHA512:
6
- metadata.gz: 7d5c69e3d6c6201d08b3bd70d100b8adaf1eb53f3aba93ab91ae3656ab67d6350239dff291fa706f67b7350e7296656ddb4f82b57b6fb9aa4469b5d3a45fdbb9
7
- data.tar.gz: aba50059b56131d6f949f9badc2327fe5a3f4d05e384452299e2e2afc45b7c24be7a93cf7d76a9c2bf5b96d1bb3a71938a3cbe3d74451a28e718430ba1784a20
6
+ metadata.gz: 3287a5de3b491b3df3573494b43afa9a099aae057e535943fd4fd277b09e4b3755b37bf566894cab253804eb39ae009ed21f887414c4dad51b81f4ce816bbba8
7
+ data.tar.gz: 623e7caec5c6bdc116c350efe7ffde325e3cdb66e22c473f69cbe6efecc65de12c1d3e794c8625e9a01c24db6f783e828105e52cfa03b6005f5409fabe5f9131
data/DESIGN.md CHANGED
@@ -48,10 +48,16 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
48
48
  - **PR source update**: a secondary Update from PR form only for PR-sourced workspaces; it fetches the latest PR head, fast-forwards the checkout, and preserves local edits without rebuilding or restarting. A persistent result message explains when rebuild is needed or when a retained stash needs manual recovery.
49
49
  - **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
50
50
  - **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
51
+ - **Lifecycle state machine**: the workspace detail page is driven by `Presentation::STATES`. Each state (`idle`, `busy`, `ready`, `stopped`, `error`/`interrupted`, `unknown`) declares which sections exist (progress, actions, environment, log, details, danger zone) and the label of the single environment-form button. Sections that are not usable or meaningful in a state are hidden, never shown disabled. The server renders the initial state and the polling script re-applies it from `presentation.sections`, so the page transitions without a reload.
52
+ - `idle` (brand new): only the environment editor plus a **Start workspace** button, with a one-line hint. No log, actions, details, or delete.
53
+ - `busy` (setup/restart running): only progress and the live log.
54
+ - `ready`: everything, with **Save & restart**.
55
+ - `stopped`: no progress from the previous run; **Save & start**.
56
+ - `error`/`interrupted`: retry action, environment editor, progress, and logs; **Save & retry**.
51
57
  - **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
52
58
  - **Actions & settings panel**: status-adjacent panel that combines routine lifecycle controls, Open workspace, Refresh status, PR update messaging, and editable environment settings so routine operations stay in one keyboard-reachable region.
53
59
  - **Open workspace**: success-styled link available when the server is ready
54
- - **Lifecycle controls**: disabled while busy; helper text distinguishes rebuilding from server-only restart
60
+ - **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
55
61
  - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
56
62
  - **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
57
63
  - **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
@@ -94,6 +94,7 @@
94
94
  started = Date.parse(data.prepare_started_at);
95
95
  completed = Date.parse(data.completed_at);
96
96
  renderStatus(view);
97
+ renderSections(view);
97
98
  node('message').textContent = data.message || '';
98
99
  node('source-update-message').textContent = data.source_update_message || '';
99
100
  node('progress').replaceChildren(...view.steps.map(renderStep));
@@ -103,6 +104,27 @@
103
104
  updateLog(data);
104
105
  }
105
106
 
107
+ function renderSections(view) {
108
+ const sections = view.sections || {};
109
+ root.querySelectorAll('[data-section]').forEach((element) => {
110
+ element.hidden = !sections[element.dataset.section];
111
+ });
112
+ const controls = node('controls');
113
+ if (controls) {
114
+ controls.hidden = !sections.actions && !sections.environment;
115
+ const title = node('actions-settings-title');
116
+ if (title && view.controls_title) title.textContent = view.controls_title;
117
+ }
118
+ const hint = node('hint');
119
+ if (hint) {
120
+ hint.textContent = view.hint || '';
121
+ hint.hidden = !view.hint;
122
+ }
123
+ node('message').hidden = view.state === 'idle';
124
+ if (window.WorkspaceEnvironment)
125
+ window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
126
+ }
127
+
106
128
  function renderStatus(view) {
107
129
  node('status').textContent = view.title;
108
130
  node('activity').hidden = !view.busy;
@@ -45,6 +45,18 @@
45
45
  });
46
46
  }
47
47
 
48
+ function submitLabel() {
49
+ return (submit && submit.textContent.trim()) || 'Save & restart';
50
+ }
51
+
52
+ function setSubmitLabel(label) {
53
+ if (!label) return;
54
+ if (submit) submit.textContent = label;
55
+ form.querySelectorAll('[data-submit-label]').forEach((element) => {
56
+ element.textContent = label;
57
+ });
58
+ }
59
+
48
60
  function setStatus(message) {
49
61
  if (!message) return;
50
62
  if (lastAnnounced === message) return;
@@ -74,7 +86,7 @@
74
86
 
75
87
  function announceUnsaved(message) {
76
88
  if (activeBusy) return;
77
- const nextMessage = message || 'Draft updated locally and not saved yet. Use Save & restart to apply changes.';
89
+ const nextMessage = message || 'Draft updated locally and not saved yet. Use ' + submitLabel() + ' to apply changes.';
78
90
  status.textContent = nextMessage;
79
91
  lastAnnounced = nextMessage;
80
92
  }
@@ -115,7 +127,7 @@
115
127
  if (!Object.prototype.hasOwnProperty.call(presets, nextValue)) return;
116
128
  replaceDraft(
117
129
  presets[nextValue],
118
- 'Preset loaded into draft only. Use Save & restart to apply changes.'
130
+ 'Preset loaded into draft only. Use ' + submitLabel() + ' to apply changes.'
119
131
  );
120
132
  });
121
133
  });
@@ -124,6 +136,7 @@
124
136
  toggleBusy,
125
137
  updateStatus,
126
138
  setStatus,
139
+ setSubmitLabel,
127
140
  syncFromSnapshot(snapshot) {
128
141
  if (!snapshot || presetButtons.length === 0 || !editor) return;
129
142
  const rendered = snapshot.editable_environment;
@@ -1,6 +1,6 @@
1
1
  <main class="dashboard">
2
2
  <header class="dashboard__header">
3
- <div><p class="eyebrow">PR preview</p><h1>PR #<%= escape(@number) %></h1></div>
3
+ <div class="dashboard__title"><p class="eyebrow">PR preview</p><h1>PR #<%= escape(@number) %></h1></div>
4
4
  <a href="/workspaces" class="button button--subtle">All workspaces</a>
5
5
  </header>
6
6
  <% unless @workspaces.empty? %>
@@ -8,7 +8,7 @@
8
8
  <h2 id="existing-heading"><%= @workspaces.one? ? 'Continue in your workspace' : 'Choose a workspace' %></h2>
9
9
  <p>Open an existing workspace to view its status, run it, or continue working.</p>
10
10
  <% @workspaces.each do |workspace| %>
11
- <article>
11
+ <article class="stack stack--tight">
12
12
  <h3><%= escape(workspace.fetch('id')) %></h3>
13
13
  <p><%= escape(workspace['branch']) %></p>
14
14
  <a href="/workspaces/<%= escape(workspace.fetch('id')) %>" class="button button--primary" aria-label="Open workspace <%= escape(workspace.fetch('id')) %>">Open workspace</a>
@@ -24,11 +24,13 @@
24
24
  <details class="status-panel">
25
25
  <summary>Create another workspace</summary>
26
26
  <% end %>
27
+ <div class="stack">
27
28
  <p>Create a fresh workspace from the PR's current head and start its server. Existing workspaces and edits stay untouched.</p>
28
29
  <form method="post" action="/pr/<%= escape(@number) %>">
29
30
  <button class="button <%= @workspaces.empty? ? 'button--primary' : 'button--subtle' %>">Create and start workspace</button>
30
31
  </form>
31
32
  <p class="muted">Creation fetches the PR. Repository setup steps continue on the workspace dashboard.</p>
33
+ </div>
32
34
  <% if @workspaces.empty? %>
33
35
  </section>
34
36
  <% else %>
@@ -17,27 +17,51 @@
17
17
  --space-5: 20px;
18
18
  --space-6: 24px;
19
19
  --space-8: 32px;
20
+ --space-10: 40px;
21
+ --panel-padding: var(--space-6);
20
22
  }
21
23
 
22
24
  * {
23
25
  box-sizing: border-box;
24
26
  }
25
27
 
28
+ /* Vertical rhythm: browsers ship per-element default margins, so spacing depended on
29
+ which tag was used. Reset them once; every gap is declared by a stack rule below
30
+ using the --space-* scale. */
31
+ h1, h2, h3, h4, p, ul, ol, dl, dd, pre, fieldset, figure { margin: 0; }
32
+ ol, ul { padding-inline-start: var(--space-6); }
33
+ legend { padding: 0; }
34
+
35
+ /* A stack separates its direct children by one step of the scale. Four steps only:
36
+ tight 8px label and control, control and its help text
37
+ snug 12px heading and its content, tightly related groups
38
+ block 16px blocks inside a panel (the default)
39
+ loose 24px panels on a page, and panel padding
40
+ Grid gaps skip [hidden] and out-of-flow children, so toggling sections never leaves
41
+ a stray gap behind (margins on siblings would). */
42
+ .stack { display: grid; gap: var(--space-4); align-content: start; }
43
+ .stack--tight { gap: var(--space-2); }
44
+ .stack--snug { gap: var(--space-3); }
45
+ .stack--loose { gap: var(--space-6); }
46
+
26
47
  [hidden] { display: none !important; }
27
48
  input { max-width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: var(--space-1); }
28
49
  input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
29
50
  textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
30
51
  summary { cursor: pointer; color: var(--muted); }
31
52
  summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
32
- .workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-block-start: var(--space-4); }
33
- .setup-error { margin-block-start: var(--space-5); border-color: var(--danger); }
53
+ .workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
54
+ /* The server-rendered action forms live in a wrapper; let them flow with the sibling button. */
55
+ #actions { display: contents; }
56
+ .setup-error { --panel-padding: var(--space-4); padding: var(--panel-padding); border-color: var(--danger); box-shadow: none; }
34
57
  .setup-error h3 { color: var(--danger); margin-block-start: 0; }
35
58
  .step-outcome { margin-inline-start: auto; font-size: 12px; }
36
59
  .activity-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: workspace-pulse 1.5s infinite; }
37
60
  @keyframes workspace-pulse { 50% { opacity: 0.3; } }
38
61
  @media (prefers-reduced-motion: reduce) { .activity-dot { animation: none; } }
39
62
  dd, h1, code { overflow-wrap: anywhere; }
40
- dd { margin-inline-start: 0; margin-block-end: var(--space-3); }
63
+ dt + dd { margin-block-start: var(--space-1); }
64
+ dd + dt { margin-block-start: var(--space-3); }
41
65
  #log { max-height: 400px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
42
66
 
43
67
  body {
@@ -69,7 +93,7 @@ a {
69
93
  }
70
94
 
71
95
  .eyebrow {
72
- margin: 0 0 var(--space-1);
96
+ margin: 0;
73
97
  color: var(--muted);
74
98
  font-size: 12px;
75
99
  letter-spacing: 0.08em;
@@ -87,6 +111,11 @@ h1 {
87
111
  color: var(--muted);
88
112
  }
89
113
 
114
+ .dashboard__title {
115
+ display: grid;
116
+ gap: var(--space-1);
117
+ }
118
+
90
119
  .header-actions {
91
120
  display: flex;
92
121
  gap: var(--space-3);
@@ -100,13 +129,30 @@ h1 {
100
129
  background: var(--bg-elevated);
101
130
  border: 1px solid var(--border);
102
131
  border-radius: var(--radius);
103
- padding: var(--space-5);
132
+ padding: var(--panel-padding);
104
133
  box-shadow: var(--shadow);
105
134
  }
106
135
 
107
- .status-row {
136
+ /* Disclosure panels keep their native layout; only the open state adds spacing below. */
137
+ .status-panel:not(details),
138
+ .controls-panel,
139
+ .error-panel,
140
+ .log-panel,
141
+ .noscript-note {
108
142
  display: grid;
109
143
  gap: var(--space-4);
144
+ align-content: start;
145
+ }
146
+
147
+ .error-panel,
148
+ .log-panel,
149
+ .danger-zone { gap: var(--space-3); }
150
+
151
+ details.status-panel[open] > summary { margin-block-end: var(--space-4); }
152
+
153
+ .status-row {
154
+ display: grid;
155
+ gap: var(--space-2) var(--space-4);
110
156
  grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
111
157
  }
112
158
 
@@ -152,7 +198,7 @@ h1 {
152
198
  }
153
199
 
154
200
  .stepper {
155
- margin: var(--space-5) 0 0;
201
+ margin: 0;
156
202
  padding: 0;
157
203
  list-style: none;
158
204
  display: grid;
@@ -217,7 +263,7 @@ h1 {
217
263
  margin: 0;
218
264
  padding: var(--space-4);
219
265
  display: grid;
220
- gap: var(--space-3);
266
+ gap: var(--space-4);
221
267
  }
222
268
 
223
269
  .env-legend {
@@ -226,16 +272,9 @@ h1 {
226
272
  padding-inline: var(--space-2);
227
273
  }
228
274
 
229
- .env-input-label {
230
- display: inline-flex;
231
- align-items: center;
232
- gap: var(--space-2);
233
- font-size: 14px;
234
- }
235
-
236
275
  .env-input-label {
237
276
  display: block;
238
- margin-block-end: var(--space-1);
277
+ font-size: 14px;
239
278
  }
240
279
 
241
280
  .env-fieldset textarea {
@@ -265,31 +304,21 @@ fieldset:disabled {
265
304
  }
266
305
 
267
306
  .controls-panel {
268
- display: grid;
269
- gap: var(--space-4);
270
307
  align-items: start;
271
308
  }
272
309
 
273
- .controls-panel h2,
274
- .controls-panel h3 {
275
- margin: 0;
276
- }
277
-
278
310
  .workspace-actions-group,
279
311
  .workspace-settings {
280
312
  display: grid;
281
313
  gap: var(--space-3);
282
314
  }
283
315
 
284
- .workspace-settings {
316
+ /* The divider only separates two groups: skip it when settings is the only one showing. */
317
+ .workspace-actions-group:not([hidden]) + .workspace-settings {
285
318
  border-top: 1px solid var(--border);
286
319
  padding-top: var(--space-4);
287
320
  }
288
321
 
289
- .workspace-actions {
290
- margin-block-start: 0;
291
- }
292
-
293
322
  .workspace-actions .button,
294
323
  .workspace-actions form {
295
324
  margin: 0;
@@ -312,6 +341,7 @@ fieldset:disabled {
312
341
  }
313
342
 
314
343
  .button {
344
+ justify-self: start;
315
345
  display: inline-flex;
316
346
  justify-content: center;
317
347
  align-items: center;
@@ -361,10 +391,25 @@ fieldset:disabled {
361
391
  .button:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
362
392
 
363
393
  .danger-zone { border-color: var(--danger); }
364
- .danger-zone h2 { margin-block-start: 0; color: var(--danger); }
394
+ .danger-zone h2 { color: var(--danger); }
365
395
  .danger-zone form { display: grid; justify-items: start; gap: var(--space-3); }
366
- .workspace-list { list-style: none; padding: 0; display: grid; gap: var(--space-3); }
367
- .workspace-list__item { display: grid; gap: var(--space-1); padding-block: var(--space-3); border-block-end: 1px solid var(--border); overflow-wrap: anywhere; }
396
+
397
+ .workspace-table { inline-size: 100%; border-collapse: collapse; }
398
+ .workspace-table th,
399
+ .workspace-table td { padding: var(--space-3) var(--space-4); text-align: start; vertical-align: middle; border-block-end: 1px solid var(--border); overflow-wrap: anywhere; }
400
+ .workspace-table th:first-child,
401
+ .workspace-table td:first-child { padding-inline-start: 0; }
402
+ .workspace-table th:last-child,
403
+ .workspace-table td:last-child { padding-inline-end: 0; }
404
+ .workspace-table thead th { padding-block-start: 0; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; }
405
+ .workspace-table tbody tr:last-child > * { padding-block-end: 0; border-block-end: 0; }
406
+ .workspace-table tbody th { font-weight: 600; }
407
+ .workspace-table__date { color: var(--muted); font-size: 13px; white-space: nowrap; }
408
+
409
+ .field { display: grid; gap: var(--space-2); max-inline-size: 28rem; font-size: 14px; }
410
+ .field input { inline-size: 100%; }
411
+
412
+ ol:not(.stepper) > li + li { margin-block-start: var(--space-2); }
368
413
 
369
414
  .button--success {
370
415
  background: color-mix(in oklab, var(--success) 22%, transparent);
@@ -379,11 +424,12 @@ fieldset:disabled {
379
424
 
380
425
  .error-panel h2,
381
426
  .log-panel h2 {
382
- margin: 0 0 var(--space-3);
427
+ margin: 0;
383
428
  font-size: 1rem;
384
429
  }
385
430
 
386
431
  .error-panel pre,
432
+ .status-panel pre,
387
433
  .log-panel pre {
388
434
  margin: 0;
389
435
  padding: var(--space-3);
@@ -408,14 +454,12 @@ fieldset:disabled {
408
454
  justify-content: space-between;
409
455
  align-items: baseline;
410
456
  gap: var(--space-3);
411
- margin-block-end: var(--space-3);
412
457
  }
413
458
 
414
459
  .log-switch {
415
460
  display: flex;
416
461
  flex-wrap: wrap;
417
462
  gap: var(--space-2);
418
- margin-block-end: var(--space-3);
419
463
  }
420
464
 
421
465
  .log-switch a {
@@ -450,7 +494,9 @@ fieldset:disabled {
450
494
  display: none !important;
451
495
  }
452
496
 
453
- .visually-hidden {
497
+ .visually-hidden,
498
+ [role='status']:empty,
499
+ [aria-live]:empty {
454
500
  position: absolute !important;
455
501
  inline-size: 1px;
456
502
  block-size: 1px;
@@ -461,7 +507,22 @@ fieldset:disabled {
461
507
  border: 0;
462
508
  }
463
509
 
510
+ p:empty:not([role]):not([aria-live]) { display: none; }
511
+
464
512
  @media (max-width: 640px) {
513
+ /* Stack each row: workspace, then status. Dates are desktop-only detail. */
514
+ .workspace-table,
515
+ .workspace-table tbody { display: block; }
516
+ .workspace-table thead,
517
+ .workspace-table th.workspace-table__date,
518
+ .workspace-table td.workspace-table__date { display: none; }
519
+ .workspace-table tr { display: grid; gap: var(--space-1); padding-block: var(--space-3); border-block-end: 1px solid var(--border); }
520
+ .workspace-table tbody tr:first-child { padding-block-start: 0; }
521
+ .workspace-table tbody tr:last-child { padding-block-end: 0; border-block-end: 0; }
522
+ .workspace-table th,
523
+ .workspace-table td { display: block; padding: 0; border: 0; }
524
+ .workspace-table tbody tr:last-child > * { padding-block-end: 0; }
525
+
465
526
  .dashboard {
466
527
  inline-size: min(100% - var(--space-4), 960px);
467
528
  padding-block: var(--space-6);
@@ -472,7 +533,8 @@ fieldset:disabled {
472
533
  .error-panel,
473
534
  .log-panel,
474
535
  .noscript-note {
475
- padding: var(--space-4);
536
+ --panel-padding: var(--space-4);
537
+ padding: var(--panel-padding);
476
538
  }
477
539
 
478
540
  .log-header {
@@ -1,6 +1,6 @@
1
1
  <main class="dashboard" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>">
2
2
  <header class="dashboard__header">
3
- <div><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p><%= field('branch') %></p></div>
3
+ <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p class="muted"><%= field('branch') %></p></div>
4
4
  <a class="button button--subtle" href="/workspaces">All workspaces</a>
5
5
  </header>
6
6
  <section class="status-panel" aria-labelledby="status">
@@ -8,8 +8,9 @@
8
8
  <h2 id="status" role="status"><%= escape(@presentation['title']) %></h2>
9
9
  <span id="activity" <%= 'hidden' unless @presentation['busy'] %>><span class="activity-dot" aria-hidden="true"></span> In progress · <span id="elapsed">—</span></span>
10
10
  </div>
11
- <p id="message"><%= field('message') %></p>
12
- <ol class="stepper" id="progress" aria-label="Setup progress">
11
+ <p id="hint" <%= 'hidden' unless @presentation['hint'] %>><%= escape(@presentation['hint']) %></p>
12
+ <p id="message" <%= 'hidden' if @presentation['state'] == 'idle' %>><%= field('message') %></p>
13
+ <ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
13
14
  <% @presentation['steps'].each do |step| %>
14
15
  <li class="step" data-step-state="<%= step['state'] %>" <%= 'aria-current="step"' if step['state'] == 'active' %>><span class="step-bullet" aria-hidden="true"></span><span><%= escape(step['label']) %></span><span class="step-outcome"><%= escape(step_outcome(step)) %></span></li>
15
16
  <% end %>
@@ -19,44 +20,45 @@
19
20
  </section>
20
21
  <p id="connection" role="status"></p>
21
22
  </section>
22
- <section class="status-panel controls-panel" aria-labelledby="actions-settings-title">
23
- <h2 id="actions-settings-title">Actions &amp; settings</h2>
24
- <div class="workspace-actions-group" aria-labelledby="actions-heading">
23
+ <section class="status-panel controls-panel" id="controls" data-section-group aria-labelledby="actions-settings-title" <%= 'hidden' unless section?('actions') || section?('environment') %>>
24
+ <h2 id="actions-settings-title"><%= escape(@presentation['controls_title']) %></h2>
25
+ <div class="workspace-actions-group" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
25
26
  <h3 id="actions-heading">Workspace actions</h3>
26
27
  <div class="workspace-actions">
27
28
  <a id="preview" class="button button--primary" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
28
29
  <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
29
- <a class="button button--subtle" href="/workspaces/<%= @id %><%= @snapshot['log_source'] == 'backend' ? '?log=backend' : '' %>">Refresh status</a>
30
30
  </div>
31
31
  <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
32
32
  </div>
33
- <div class="workspace-settings" aria-labelledby="environment-overrides">
34
- <h3 id="environment-overrides">Editable environment</h3>
35
- <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
33
+ <div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
34
+ <h3 id="environment-overrides">Environment</h3>
35
+ <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
36
36
  <fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
37
37
  <legend class="env-legend">Workspace settings</legend>
38
38
  <% unless environment_presets.empty? %>
39
- <div data-preset-controls hidden>
39
+ <div class="stack stack--tight" data-preset-controls hidden>
40
40
  <p class="env-input-label" id="environment-preset-label">Preset</p>
41
41
  <div class="env-preset-buttons" role="group" aria-labelledby="environment-preset-label" aria-describedby="environment-preset-help">
42
42
  <% environment_presets.each_key do |name| %>
43
43
  <button type="button" class="button button--subtle env-preset-button" data-environment-preset="<%= escape(name) %>" aria-pressed="false"><%= escape(name) %></button>
44
44
  <% end %>
45
45
  </div>
46
- <p class="muted" id="environment-preset-help">Select a preset, then Save &amp; restart to apply.</p>
46
+ <p class="muted" id="environment-preset-help">Presets decide which app this workspace runs. Select one, then use <strong data-submit-label><%= escape(@presentation['environment_submit']) %></strong> to apply it.</p>
47
47
  </div>
48
48
  <% end %>
49
- <label class="env-input-label" for="editable-env">Environment variables</label>
50
- <textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"><%= escape(editable_environment_text) %></textarea>
49
+ <div class="stack stack--tight">
50
+ <label class="env-input-label" for="editable-env">Environment variables</label>
51
+ <textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"><%= escape(editable_environment_text) %></textarea>
52
+ </div>
51
53
  </fieldset>
52
54
  <div class="env-actions">
53
- <button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>>Save &amp; restart</button>
55
+ <button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>><%= escape(@presentation['environment_submit']) %></button>
54
56
  </div>
55
57
  <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
56
58
  </form>
57
59
  </div>
58
60
  </section>
59
- <section class="log-panel" aria-labelledby="log-title">
61
+ <section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
60
62
  <div class="log-header"><h2 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h2><time id="updated"><%= field('updated_at') %></time></div>
61
63
  <nav class="log-switch" aria-label="Log source">
62
64
  <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
@@ -64,8 +66,8 @@
64
66
  </nav>
65
67
  <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
66
68
  </section>
67
- <details class="status-panel"><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl></details>
68
- <section class="status-panel danger-zone" aria-labelledby="danger-heading">
69
+ <details class="status-panel" data-section="details" <%= 'hidden' unless section?('details') %>><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl></details>
70
+ <section class="status-panel danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
69
71
  <h2 id="danger-heading">Danger Zone</h2>
70
72
  <p>Deleting this workspace stops its server and removes its worktree. <strong>Discard uncommitted and untracked changes</strong> permanently. The branch and committed work are preserved.</p>
71
73
  <form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
@@ -15,16 +15,16 @@ module Workspaces
15
15
  <main class="dashboard"><header class="dashboard__header"><h1>Workspaces</h1></header>
16
16
  <section class="status-panel"><h2>Create workspace</h2>
17
17
  <p>Choose a branch, PR number, or new branch. Every submission creates a separate managed worktree.</p>
18
- <form method="post" action="/workspaces/create">
19
- <p><label>Existing branch <input name="branch" placeholder="main"></label></p>
20
- <p><label>PR number <input name="pr" inputmode="numeric" placeholder="43828"></label></p>
21
- <p><label>New branch <input name="new_branch" placeholder="agent/fix-checkout"></label></p>
22
- <p><label>Start new branch from <input name="from" placeholder="HEAD"></label></p>
18
+ <form method="post" action="/workspaces/create" class="stack">
19
+ <label class="field">Existing branch <input name="branch" placeholder="main"></label>
20
+ <label class="field">PR number <input name="pr" inputmode="numeric" placeholder="43828"></label>
21
+ <label class="field">New branch <input name="new_branch" placeholder="agent/fix-checkout"></label>
22
+ <label class="field">Start new branch from <input name="from" placeholder="HEAD"></label>
23
23
  <button class="button button--primary">Create workspace</button>
24
24
  </form></section>
25
- <section class="status-panel"><h2>Managed workspaces</h2><ul class="workspace-list">
26
- #{workspaces.map { |workspace| view.inventory_item(workspace) }.join}
27
- </ul></section></main>
25
+ <section class="status-panel"><h2>Managed workspaces</h2>
26
+ #{view.inventory_table(workspaces)}
27
+ </section></main>
28
28
  HTML
29
29
  end
30
30
 
@@ -90,22 +90,42 @@ module Workspaces
90
90
  ERB::Util.html_escape(value.to_s)
91
91
  end
92
92
 
93
- def inventory_item(workspace)
93
+ # Tabular data gets a real table. The workspace ID is both the row header and the
94
+ # link, so no separate name/branch/link columns are needed. Narrow screens hide the
95
+ # date columns and stack each row (see styles.css).
96
+ def inventory_table(workspaces)
97
+ return '<p class="muted">No workspaces yet.</p>' if workspaces.empty?
98
+
99
+ <<~HTML
100
+ <table class="workspace-table">
101
+ <thead><tr><th scope="col">Workspace</th><th scope="col">Status</th><th scope="col" class="workspace-table__date">Created</th><th scope="col" class="workspace-table__date">Last startup</th></tr></thead>
102
+ <tbody>
103
+ #{workspaces.map { |workspace| inventory_row(workspace) }.join}
104
+ </tbody>
105
+ </table>
106
+ HTML
107
+ end
108
+
109
+ def inventory_row(workspace)
94
110
  id = escape(Config.validate_id!(workspace.fetch('id')))
111
+ status = escape(workspace['status'] || 'idle')
95
112
  running = workspace['running'] ? 'Running' : 'Not running'
96
113
  <<~HTML
97
- <li class="workspace-list__item">
98
- <a href="/workspaces/#{id}">#{id}</a>
99
- <span class="muted">#{escape(workspace['branch'])}</span>
100
- <span>#{running} · #{escape(workspace['status'] || 'idle')}#{workspace['active'] ? ' (busy)' : ''}</span>
101
- <span class="muted">Created <time datetime="#{escape(workspace['created_at'])}">#{escape(workspace['created_at'] || 'Unavailable')}</time></span>
102
- <span class="muted">Last startup <time datetime="#{escape(workspace['started_at'])}">#{escape(workspace['started_at'] || 'Never')}</time></span>
103
- </li>
114
+ <tr>
115
+ <th scope="row"><a href="/workspaces/#{id}">#{id}</a></th>
116
+ <td><span class="status-pill" data-status="#{status}"><span class="status-dot" aria-hidden="true"></span>#{running} · #{status}#{workspace['active'] ? ' (busy)' : ''}</span></td>
117
+ <td class="workspace-table__date"><time datetime="#{escape(workspace['created_at'])}">#{escape(workspace['created_at'] || 'Unavailable')}</time></td>
118
+ <td class="workspace-table__date"><time datetime="#{escape(workspace['started_at'])}">#{escape(workspace['started_at'] || 'Never')}</time></td>
119
+ </tr>
104
120
  HTML
105
121
  end
106
122
 
107
123
  private
108
124
 
125
+ def section?(name)
126
+ @presentation.fetch('sections', {})[name]
127
+ end
128
+
109
129
  def field(name)
110
130
  escape(@snapshot[name])
111
131
  end
@@ -6,13 +6,45 @@ module Workspaces
6
6
  ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
7
7
  'restart' => 'Restart server', 'stop' => 'Stop server' }.freeze
8
8
 
9
+ SECTIONS = %w(progress actions environment log details danger).freeze
10
+ MANAGEABLE = SECTIONS
11
+
12
+ # Workspace lifecycle state machine. Each state declares only what is usable and
13
+ # meaningful there; everything else is hidden rather than shown disabled.
14
+ #
15
+ # idle ──start──▶ busy ──▶ ready ◀──▶ busy
16
+ # │ │
17
+ # ▼ ▼
18
+ # error stopped ──start──▶ busy
19
+ # (interrupted)
20
+ #
21
+ # `environment_submit` labels the single environment-form button, which saves the
22
+ # settings and (re)starts the workspace, so a brand new workspace needs only that.
23
+ STATES = {
24
+ 'idle' => {
25
+ sections: %w(environment),
26
+ environment_submit: 'Start workspace', controls_title: 'Set up your workspace',
27
+ hint: 'Choose the environment you want to preview below, then start the workspace. ' \
28
+ 'Setup usually takes a few minutes.'
29
+ },
30
+ 'busy' => { sections: %w(progress log), environment_submit: 'Save & restart' },
31
+ 'ready' => { sections: MANAGEABLE, environment_submit: 'Save & restart' },
32
+ 'stopped' => { sections: %w(actions environment log details danger), environment_submit: 'Save & start' },
33
+ 'error' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
34
+ 'interrupted' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
35
+ 'unknown' => { sections: %w(log details), environment_submit: 'Save & restart' }
36
+ }.freeze
37
+
9
38
  def initialize(snapshot)
10
39
  @snapshot = snapshot
11
40
  end
12
41
 
13
42
  def to_h
14
43
  { 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
15
- 'actions' => actions, 'ready' => status == 'ready' && !busy? }
44
+ 'actions' => actions, 'ready' => status == 'ready' && !busy?,
45
+ 'state' => state, 'sections' => sections, 'hint' => definition[:hint],
46
+ 'controls_title' => definition.fetch(:controls_title, 'Actions & settings'),
47
+ 'environment_submit' => definition[:environment_submit] }
16
48
  end
17
49
 
18
50
  private
@@ -25,6 +57,22 @@ module Workspaces
25
57
  @snapshot['active'] || status == 'preparing'
26
58
  end
27
59
 
60
+ def state
61
+ return 'busy' if busy?
62
+
63
+ STATES.key?(status) ? status : 'unknown'
64
+ end
65
+
66
+ def definition
67
+ STATES.fetch(state)
68
+ end
69
+
70
+ def sections
71
+ visible = definition[:sections].dup
72
+ visible.delete('progress') if steps.empty? || %w(idle stopped).include?(state)
73
+ SECTIONS.to_h { |name| [name, visible.include?(name)] }
74
+ end
75
+
28
76
  def title
29
77
  return 'Workspace busy' if @snapshot['active'] && !busy_status?
30
78
  return @snapshot['message'] || 'Running setup' if status == 'preparing'
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.7'
2
+ VERSION = '0.1.9'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workspaces
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Workspaces contributors
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-07 00:00:00.000000000 Z
11
+ date: 2026-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack