workspaces 0.1.10 → 0.1.11

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: 1974af7e7acc6d19fe556efa4af5e2f9e05635d8df564aa6b3e7e7f8a9096370
4
- data.tar.gz: d819cb05a41259c8f9b0c7e49d42155f7b072daa88bd839b44351ddec7445b51
3
+ metadata.gz: fc21704825da052eb9cde1ea64cf3ab4958242f08492cf2a5b538d6fd97ebca3
4
+ data.tar.gz: 46dc25654e97ef6fb461058470dcfeb56e5a82720fa6df875c5b3c34b5d273a6
5
5
  SHA512:
6
- metadata.gz: ad1115622e9f3c85fb12c63ba8268161054cca32675a081b6d43b5a11b1b5f1a55c0491a143631277c12aa2d019e10eb228acfcdb26fecce719a45f0336c8f3f
7
- data.tar.gz: 236ca0df90dc5e0403fca4d494bcbeabf90117aabbd12c12175d3eecf4d2475fc36e432430e3ece7c61c651188c234daeef041ed148812d80ef71e983fda5afe
6
+ metadata.gz: daa43e3151dd5bb2e4239ae61ca9322101eb8793d437a91782dfa71ec078ce42721d384bea49564c0929a087b9476ab8c76d44860ad806ab3dab7e4145352193
7
+ data.tar.gz: 61d43cac06ed0948041320abf6d0a876697241a26063f5aa3f85537580cea660c8e0c7af2e8589fad80a32c6d3ba8432140e959a9fee70ba791ca92f3c81534a
data/DESIGN.md CHANGED
@@ -48,19 +48,24 @@ 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
+ - **Three layers**: the workspace page leads with the friendly, the deliberate, then the technical. Each layer is shown or collapsed by lifecycle state, driven by `Presentation::STATES`:
52
+ 1. **Lead** (always visible): a plain-language headline, one sentence, and at most one primary action (a large button). The setup steps never appear here.
53
+ 2. **Settings** (a `<details>` titled *Change settings*, or *Choose what to preview* on a brand new workspace): presets first, as buttons, then the environment variables. The one submit button names what it does (Start workspace, Save & restart, Save & start, Save & retry). It starts open only for a new workspace.
54
+ 3. **Technical details** (one `<details>`): setup steps with timings, the live log, workspace controls (rebuild, restart, stop, update from PR), preview URL, branch and path, and the Danger Zone. It starts collapsed, and opens by itself while setup runs or after a failure so a cause is never hidden.
55
+ - **Lead by state**:
56
+ - `idle`: "Let's get your workspace running", with the settings open and *Start workspace* in the form.
57
+ - `busy`: "Setting things up…", with the current step as a quiet line and the elapsed time. Technical details open so progress and the log can be watched.
58
+ - `ready`: "Your workspace is ready" with a large **Open workspace** button. Everything else is collapsed.
59
+ - `stopped`: "Your workspace is paused" with a large **Start workspace** button.
60
+ - `error`/`interrupted`: a plain "Something didn't go to plan" with a large **Retry** button, the error shown, and technical details open.
61
+ - `unknown`: a minimal view with technical details open.
62
+ - **Disclosure behavior**: the server renders the initial open state and the polling script re-applies it from `presentation.open`, but only when the state changes. A section the user opened or closed is left alone on later polls. Sections that are not meaningful in a state are hidden, never shown disabled.
57
63
  - **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
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.
59
64
  - **Open workspace**: success-styled link available when the server is ready
60
65
  - **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
61
66
  - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
62
67
  - **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.
63
- - **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
68
+ - **Preview destination**: a small muted URL in Technical details, visible before readiness; the large Open workspace button is reserved for ready servers.
64
69
  - **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
65
70
  - **Environment presets (JS-enhanced)**: optional preset selector is hidden by default and revealed only when JavaScript is active and presets exist. Selecting a preset replaces the entire draft editor content and announces unsaved state without submitting. Draft edits remain local until Save & restart.
66
71
  - **Danger Zone**: isolated section on the detail page with explicit loss-of-uncommitted-and-untracked-work copy, an exact-workspace-ID confirmation field, and a destructive action. Deletion remains possible without JavaScript, is disabled while setup is busy, and never appears as a list-row action.
@@ -14,7 +14,7 @@
14
14
  let actionsSignature = null;
15
15
  function toggleBusyState(nextBusy) {
16
16
  busy = !!nextBusy;
17
- root.querySelectorAll('.workspace-actions button').forEach((button) => {
17
+ root.querySelectorAll('.workspace-actions button, .lead__action button').forEach((button) => {
18
18
  button.disabled = busy;
19
19
  });
20
20
  if (window.WorkspaceEnvironment)
@@ -95,7 +95,7 @@
95
95
  completed = Date.parse(data.completed_at);
96
96
  renderStatus(view);
97
97
  renderSections(view);
98
- node('message').textContent = data.message || '';
98
+ if (node('message')) node('message').textContent = data.message || '';
99
99
  node('source-update-message').textContent = data.source_update_message || '';
100
100
  node('progress').replaceChildren(...view.steps.map(renderStep));
101
101
  renderActions(view.actions);
@@ -109,25 +109,69 @@
109
109
  root.querySelectorAll('[data-section]').forEach((element) => {
110
110
  element.hidden = !sections[element.dataset.section];
111
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';
112
+ renderLead(view);
113
+ applyDisclosureState(view);
124
114
  if (window.WorkspaceEnvironment)
125
115
  window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
126
116
  }
127
117
 
118
+ function renderLead(view) {
119
+ node('status').textContent = view.headline || view.title;
120
+ node('summary').textContent = view.summary || '';
121
+ const subtitle = node('subtitle');
122
+ const activity = node('activity');
123
+ if (subtitle) {
124
+ const text = view.subtitle || '';
125
+ subtitle.hidden = !text;
126
+ // The text has its own element, so it can be set whether or not the server rendered any.
127
+ const target = node('subtitle-text');
128
+ if (target) target.textContent = text;
129
+ }
130
+ if (activity) activity.hidden = !view.busy;
131
+ const title = node('environment-title');
132
+ if (title && view.environment_title) title.textContent = view.environment_title;
133
+ renderLeadAction(view.lead_action);
134
+ }
135
+
136
+ // The lead action is a lifecycle post (start/retry) or, when ready, the preview link
137
+ // rendered separately as #preview. Rebuild only when it actually changes.
138
+ let leadSignature = null;
139
+ function renderLeadAction(action) {
140
+ const holder = node('lead-action');
141
+ if (!holder) return;
142
+ const signature = JSON.stringify(action || null);
143
+ if (signature === leadSignature) return;
144
+ leadSignature = signature;
145
+ holder.replaceChildren();
146
+ if (!action || action.kind !== 'post' || !allowedActions.includes(action.key)) return;
147
+ const form = document.createElement('form');
148
+ form.method = 'post';
149
+ form.action = base + '/' + action.key;
150
+ const button = document.createElement('button');
151
+ button.className = 'button button--primary button--large';
152
+ button.textContent = action.label;
153
+ form.append(button);
154
+ holder.append(form);
155
+ toggleBusyState(busy);
156
+ }
157
+
158
+ // A disclosure opens by itself when its state says so (a failure must never be hidden,
159
+ // a fresh workspace needs its settings), but we never close one the user opened, and
160
+ // we only apply the state's preference when the state changes, not on every poll.
161
+ let lastDisclosureState = root.dataset.disclosureState || null;
162
+ function applyDisclosureState(view) {
163
+ if (view.state === lastDisclosureState) return;
164
+ lastDisclosureState = view.state;
165
+ const open = view.open || {};
166
+ [['environment-disclosure', open.environment], ['technical-disclosure', open.technical]].forEach(
167
+ ([id, shouldOpen]) => {
168
+ const element = node(id);
169
+ if (element) element.open = !!shouldOpen;
170
+ }
171
+ );
172
+ }
173
+
128
174
  function renderStatus(view) {
129
- node('status').textContent = view.title;
130
- node('activity').hidden = !view.busy;
131
175
  node('preview').hidden = !view.ready;
132
176
  node('error').textContent = view.error || '';
133
177
  node('error-panel').hidden = !view.error;
@@ -148,7 +148,64 @@ h1 {
148
148
  .log-panel,
149
149
  .danger-zone { gap: var(--space-3); }
150
150
 
151
- details.status-panel[open] > summary { margin-block-end: var(--space-4); }
151
+
152
+ /* Lead: a friendly headline, one sentence, and at most one big button. */
153
+ .lead { gap: var(--space-4); padding-block: var(--space-8); }
154
+ .lead__text { display: grid; gap: var(--space-2); }
155
+ .lead h2 { font-size: clamp(1.4rem, 2vw + 1rem, 1.9rem); line-height: 1.2; }
156
+ .lead__summary { max-inline-size: 38rem; color: var(--muted); font-size: 1.05rem; line-height: 1.5; }
157
+ .lead__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
158
+ .lead__action:empty { display: none; }
159
+ .button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius); }
160
+
161
+ /* Disclosures: quiet by default, a clear label and a one-line hint of what is inside. */
162
+ /* A disclosure has to read as a control. The native marker is lost once a summary is a flex
163
+ container, so draw our own chevron; the whole header is the click target, with a hover
164
+ fill and a keyboard focus ring. */
165
+ /* Two classes so the narrow-screen panel padding below cannot re-add padding around the summary. */
166
+ .status-panel.disclosure { padding: 0; }
167
+ .disclosure > summary {
168
+ display: flex;
169
+ flex-wrap: wrap;
170
+ align-items: baseline;
171
+ gap: var(--space-1) var(--space-3);
172
+ padding: var(--space-4) calc(var(--space-6) + var(--space-8)) var(--space-4) var(--panel-padding);
173
+ position: relative;
174
+ list-style: none;
175
+ color: var(--text);
176
+ font-weight: 600;
177
+ border-radius: var(--radius);
178
+ cursor: pointer;
179
+ user-select: none;
180
+ transition: background-color 120ms ease;
181
+ }
182
+ .disclosure > summary::-webkit-details-marker { display: none; }
183
+ .disclosure > summary::after {
184
+ content: '';
185
+ position: absolute;
186
+ inset-inline-end: var(--panel-padding);
187
+ inset-block-start: 50%;
188
+ inline-size: 8px;
189
+ block-size: 8px;
190
+ margin-block-start: -6px;
191
+ border-inline-end: 2px solid var(--muted);
192
+ border-block-end: 2px solid var(--muted);
193
+ transform: rotate(45deg);
194
+ transition: transform 160ms ease, border-color 120ms ease;
195
+ }
196
+ .disclosure[open] > summary::after { margin-block-start: -2px; transform: rotate(225deg); }
197
+ .disclosure > summary:hover { background: color-mix(in oklab, var(--accent) 8%, transparent); }
198
+ .disclosure > summary:hover::after { border-color: var(--text); }
199
+ .disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
200
+ .disclosure[open] > summary { border-end-start-radius: 0; border-end-end-radius: 0; border-block-end: 1px solid var(--border); margin-block-end: 0; }
201
+ .disclosure > :not(summary) { margin: var(--panel-padding); }
202
+ .disclosure__hint { color: var(--muted); font-size: 13px; font-weight: 400; }
203
+ @media (prefers-reduced-motion: reduce) { .disclosure > summary, .disclosure > summary::after { transition: none; } }
204
+ .disclosure .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
205
+ .disclosure .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
206
+ .disclosure .log-panel { padding: 0; }
207
+ .disclosure .danger-zone h3 { color: var(--danger); font-size: 1rem; }
208
+ .disclosure h3 { font-size: 1rem; }
152
209
 
153
210
  .status-row {
154
211
  display: grid;
@@ -1,37 +1,27 @@
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'] %>">
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'] %>" data-disclosure-state="<%= escape(@presentation['state']) %>">
2
2
  <header class="dashboard__header">
3
- <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p class="muted"><%= field('branch') %></p></div>
3
+ <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1></div>
4
4
  <a class="button button--subtle" href="/workspaces">All workspaces</a>
5
5
  </header>
6
- <section class="status-panel" aria-labelledby="status">
7
- <div class="status-row">
8
- <h2 id="status" role="status"><%= escape(@presentation['title']) %></h2>
9
- <span id="activity" <%= 'hidden' unless @presentation['busy'] %>><span class="activity-dot" aria-hidden="true"></span> In progress · <span id="elapsed">—</span></span>
6
+ <section class="status-panel lead" id="lead" data-section="lead" aria-labelledby="status" <%= 'hidden' unless section?('lead') %>>
7
+ <div class="lead__text">
8
+ <h2 id="status" role="status"><%= escape(@presentation['headline']) %></h2>
9
+ <p id="summary" class="lead__summary"><%= escape(@presentation['summary']) %></p>
10
+ <p id="subtitle" class="muted" <%= 'hidden' unless @presentation['subtitle'] %>><span id="subtitle-text"><%= escape(@presentation['subtitle']) %></span><span id="activity" <%= 'hidden' unless @presentation['busy'] %>> · <span class="activity-dot" aria-hidden="true"></span> <span id="elapsed">—</span></span></p>
10
11
  </div>
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') %>>
14
- <% @presentation['steps'].each do |step| %>
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>
16
- <% end %>
17
- </ol>
12
+ <div class="lead__actions">
13
+ <a id="preview" class="button button--primary button--large" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
14
+ <div id="lead-action" class="lead__action"><%= lead_action_form(@presentation['lead_action']) %></div>
15
+ </div>
16
+ <p id="message" class="muted" hidden><%= field('message') %></p>
18
17
  <section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
19
18
  <h3>What went wrong</h3><pre id="error"><%= escape(@presentation['error']) %></pre>
20
19
  </section>
21
20
  <p id="connection" role="status"></p>
22
21
  </section>
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') %>>
26
- <h3 id="actions-heading">Workspace actions</h3>
27
- <div class="workspace-actions">
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>
29
- <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
30
- </div>
31
- <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
32
- </div>
33
- <div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
34
- <h3 id="environment-overrides">Environment</h3>
22
+ <details class="status-panel disclosure" id="environment-disclosure" data-section="environment" <%= 'open' if @presentation.dig('open', 'environment') %> <%= 'hidden' unless section?('environment') %>>
23
+ <summary><span id="environment-title"><%= escape(@presentation['environment_title']) %></span><span class="disclosure__hint">Presets and environment variables</span></summary>
24
+ <div class="workspace-settings" aria-labelledby="environment-title">
35
25
  <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
36
26
  <fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
37
27
  <legend class="env-legend">Workspace settings</legend>
@@ -57,25 +47,45 @@
57
47
  <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
58
48
  </form>
59
49
  </div>
60
- </section>
61
- <section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
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>
63
- <nav class="log-switch" aria-label="Log source">
64
- <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
65
- <a href="?log=backend" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %>>Backend log</a>
66
- </nav>
67
- <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
68
- </section>
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') %>>
71
- <h2 id="danger-heading">Danger Zone</h2>
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>
73
- <form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
74
- <label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
75
- <input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
76
- <p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
77
- <button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
78
- </form>
79
- </section>
50
+ </details>
51
+ <details class="status-panel disclosure" id="technical-disclosure" data-section="technical" <%= 'open' if @presentation.dig('open', 'technical') %> <%= 'hidden' unless section?('technical') %>>
52
+ <summary><span>Technical details</span><span class="disclosure__hint">Setup steps, logs and workspace controls</span></summary>
53
+ <div class="technical stack stack--loose">
54
+ <ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
55
+ <% @presentation['steps'].each do |step| %>
56
+ <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>
57
+ <% end %>
58
+ </ol>
59
+ <div class="workspace-actions-group stack stack--snug" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
60
+ <h3 id="actions-heading">Workspace controls</h3>
61
+ <div class="workspace-actions">
62
+ <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
63
+ </div>
64
+ <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
65
+ </div>
66
+ <section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
67
+ <div class="log-header"><h3 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h3><time id="updated"><%= field('updated_at') %></time></div>
68
+ <nav class="log-switch" aria-label="Log source">
69
+ <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
70
+ <a href="?log=backend" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %>>Backend log</a>
71
+ </nav>
72
+ <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
73
+ </section>
74
+ <section class="stack stack--snug" data-section="details" <%= 'hidden' unless section?('details') %>>
75
+ <h3>Workspace details</h3>
76
+ <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>
77
+ </section>
78
+ <section class="danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
79
+ <h3 id="danger-heading">Danger Zone</h3>
80
+ <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>
81
+ <form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
82
+ <label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
83
+ <input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
84
+ <p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
85
+ <button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
86
+ </form>
87
+ </section>
88
+ </div>
89
+ </details>
80
90
  <noscript><a href="/workspaces/<%= @id %>">Refresh status</a></noscript>
81
91
  </main>
@@ -161,6 +161,15 @@ module Workspaces
161
161
  JSON.generate(environment_presets)
162
162
  end
163
163
 
164
+ # The lead's primary action. `open` is rendered separately as a link (it needs the
165
+ # preview URL and must stay a real anchor), so only lifecycle posts produce a form.
166
+ def lead_action_form(action)
167
+ return '' unless action && action['kind'] == 'post'
168
+
169
+ "<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
170
+ "<button class=\"button button--primary button--large\">#{escape(action.fetch('label'))}</button></form>"
171
+ end
172
+
164
173
  def action_form(action)
165
174
  style = action['primary'] ? 'button--primary' : 'button--subtle'
166
175
  "<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
@@ -6,8 +6,13 @@ 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
9
+ # Three layers, from friendly to technical. `lead` is the headline, one sentence and at
10
+ # most one primary action. `environment` (presets and variables) is the area for
11
+ # deliberately changing something. `technical` is one disclosure holding setup steps,
12
+ # logs, workspace details, lifecycle controls and deletion; it is never the first thing
13
+ # a user reads, but it opens by itself when something needs attention.
14
+ SECTIONS = %w(lead progress actions environment technical log details danger).freeze
15
+ TECHNICAL = %w(technical progress actions log details danger).freeze
11
16
 
12
17
  # Workspace lifecycle state machine. Each state declares only what is usable and
13
18
  # meaningful there; everything else is hidden rather than shown disabled.
@@ -20,19 +25,60 @@ module Workspaces
20
25
  #
21
26
  # `environment_submit` labels the single environment-form button, which saves the
22
27
  # settings and (re)starts the workspace, so a brand new workspace needs only that.
28
+ #
29
+ # Per state: `headline` and `summary` are plain language; `open_environment` and
30
+ # `open_technical` say which disclosures start expanded. A failure always opens the
31
+ # technical section so the cause is visible without hunting for it.
23
32
  STATES = {
24
33
  '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.'
34
+ sections: %w(lead environment),
35
+ headline: "Let's get your workspace running",
36
+ summary: 'Pick the version of the site you want to preview, then start. Setup usually takes a few minutes.',
37
+ environment_submit: 'Start workspace', environment_title: 'Choose what to preview',
38
+ open_environment: true, open_technical: false
39
+ },
40
+ 'busy' => {
41
+ sections: %w(lead technical progress log),
42
+ headline: 'Setting things up…',
43
+ summary: "This usually takes a few minutes. We'll take you to the preview when it's ready.",
44
+ environment_submit: 'Save & restart', environment_title: 'Change settings',
45
+ open_environment: false, open_technical: true
46
+ },
47
+ 'ready' => {
48
+ sections: %w(lead environment) + TECHNICAL,
49
+ headline: 'Your workspace is ready',
50
+ summary: 'Everything is set up and running. Open it to see your changes.',
51
+ environment_submit: 'Save & restart', environment_title: 'Change settings',
52
+ open_environment: false, open_technical: false
53
+ },
54
+ 'stopped' => {
55
+ sections: %w(lead environment) + TECHNICAL,
56
+ headline: 'Your workspace is paused',
57
+ summary: 'Start it again to pick up where you left off.',
58
+ environment_submit: 'Save & start', environment_title: 'Change settings',
59
+ open_environment: false, open_technical: false
60
+ },
61
+ 'error' => {
62
+ sections: %w(lead environment) + TECHNICAL,
63
+ headline: "Something didn't go to plan",
64
+ summary: 'Setup stopped before it finished. You can try again, or change settings first.',
65
+ environment_submit: 'Save & retry', environment_title: 'Change settings',
66
+ open_environment: false, open_technical: true
67
+ },
68
+ 'interrupted' => {
69
+ sections: %w(lead environment) + TECHNICAL,
70
+ headline: 'Setup was interrupted',
71
+ summary: 'It stopped before it finished. Try again to pick up from the start.',
72
+ environment_submit: 'Save & retry', environment_title: 'Change settings',
73
+ open_environment: false, open_technical: true
29
74
  },
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' }
75
+ 'unknown' => {
76
+ sections: %w(lead technical log details),
77
+ headline: 'Workspace status unknown',
78
+ summary: 'Open the technical details to see what the workspace is doing.',
79
+ environment_submit: 'Save & restart', environment_title: 'Change settings',
80
+ open_environment: false, open_technical: true
81
+ }
36
82
  }.freeze
37
83
 
38
84
  def initialize(snapshot)
@@ -42,9 +88,12 @@ module Workspaces
42
88
  def to_h
43
89
  { 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
44
90
  '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] }
91
+ 'state' => state, 'sections' => sections,
92
+ 'headline' => definition[:headline], 'summary' => definition[:summary],
93
+ 'subtitle' => subtitle, 'lead_action' => lead_action,
94
+ 'environment_title' => definition[:environment_title],
95
+ 'environment_submit' => definition[:environment_submit],
96
+ 'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
48
97
  end
49
98
 
50
99
  private
@@ -73,6 +122,23 @@ module Workspaces
73
122
  SECTIONS.to_h { |name| [name, visible.include?(name)] }
74
123
  end
75
124
 
125
+ # The single most useful thing to do next, or nil when the user should just wait.
126
+ # `open` links straight to the preview; the others are lifecycle form posts.
127
+ def lead_action
128
+ case state
129
+ when 'ready' then { 'kind' => 'open', 'label' => 'Open workspace' }
130
+ when 'stopped' then { 'kind' => 'post', 'key' => 'start', 'label' => 'Start workspace' }
131
+ when 'error', 'interrupted' then retry_action.merge('kind' => 'post')
132
+ end
133
+ end
134
+
135
+ # What is happening right now, in the quiet line under the headline.
136
+ def subtitle
137
+ return nil unless state == 'busy'
138
+
139
+ @snapshot['message']
140
+ end
141
+
76
142
  def title
77
143
  return 'Workspace busy' if @snapshot['active'] && !busy_status?
78
144
  return @snapshot['message'] || 'Running setup' if status == 'preparing'
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.10'
2
+ VERSION = '0.1.11'
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.10
4
+ version: 0.1.11
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-13 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack