workspaces 0.1.10 → 0.1.12

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: 1425a9356962c4990de3a5a0f5ffb1b8660325bcff165bfb8336fb7c468696ef
4
+ data.tar.gz: 878aff1b14d9ec8f13902a9ac3abd35e47cce3536d640d25b15a8e5a50426936
5
5
  SHA512:
6
- metadata.gz: ad1115622e9f3c85fb12c63ba8268161054cca32675a081b6d43b5a11b1b5f1a55c0491a143631277c12aa2d019e10eb228acfcdb26fecce719a45f0336c8f3f
7
- data.tar.gz: 236ca0df90dc5e0403fca4d494bcbeabf90117aabbd12c12175d3eecf4d2475fc36e432430e3ece7c61c651188c234daeef041ed148812d80ef71e983fda5afe
6
+ metadata.gz: '059361fdfac7d7328f4f50d31efeb647656375582a4d178f4f0d6f7c62affae21eaf53f67cf9590da272f29803fdefa904ebba36e18c8b570b97b12ad9c86ca3'
7
+ data.tar.gz: c623574a0d2105f00b22ed460c597941e09fead329a335d47f01c9967d65ec84da098e23c08c4fdb0ace7358c6d7c929f4d5fa6a228bf21fb12a960a75d4b8c8
data/DESIGN.md CHANGED
@@ -48,19 +48,28 @@ 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*, with a *Preset: name* badge in its header whenever the saved settings exactly match a preset, so the choice is visible while it is closed, 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 step logs with timings, the web server 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
66
+ - **Step logs (GitHub Actions style)**: setup output is split by recipe step. Each step is a `<details>` row with a status icon (ring, pulsing ring, check or cross), its name, how long it took and a chevron; opening it shows only that step's output. A step that is running or has failed starts open and a completed or waiting one starts closed. The script updates rows in place by step id, and applies the open preference only when a step's state changes, so a row the reader toggled is never reopened or reclosed and what they are reading is never rebuilt.
67
+ - **Capture**: `StepLog` wraps the combined log. Every line still goes to the combined `<id>.setup.log`; while a step is current the same scrubbed text also goes to `logs/<id>.steps/<step-id>.log`. A step's file is truncated when it begins, the files are cleared when a run starts, and a failing step records its error message in its own file.
68
+ - **Read**: the snapshot carries `step_logs` (the tail of each step that has output, with a `truncated` flag) for the setup view only, and `backend_log_available`.
69
+ - **Tabs**: *Setup steps* (the accordions) and *Web server log*. The server tab appears only once the backend log has content. The combined log stays reachable as a final, collapsed *Full setup log* row, which also covers workspaces set up before step files existed.
61
70
  - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
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.
63
- - **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
71
+ - **Log source switcher**: two native links inside one log panel (Setup steps, Web server log), with current selection announced through `aria-current`; only the selected view is loaded and refreshed. Setup steps is the default.
72
+ - **Preview destination**: a small muted URL in Technical details, visible before readiness; the large Open workspace button is reserved for ready servers.
64
73
  - **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
74
  - **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
75
  - **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.
data/README.md CHANGED
@@ -42,13 +42,17 @@ create from a pull request with `create --pr NUMBER`, or create a new branch wit
42
42
  ### Step timings
43
43
 
44
44
  Per-step durations are persisted in seconds in workspace step status JSON, so they
45
- remain available after the command exits. Timings also appear in CLI progress,
46
- the workspace setup log, and the dashboard's step list.
45
+ remain available after the command exits, along with the wall-clock `total_seconds` of
46
+ the last run. Timings also appear in CLI progress, the workspace setup log, and the
47
+ dashboard. Everywhere they are shown as `0.5s`, `12.3s`, `1m 10s` or `1h 5m 3s`, never
48
+ as a large bare seconds count. The dashboard's Full setup log row shows the total
49
+ (`complete · 2m 17s`).
47
50
 
48
51
  A background step measures only process launch, not the server's lifetime or
49
52
  readiness. The following readiness step measures its own wait separately.
50
53
  Interrupted steps have no recorded duration. Step timings are not the total
51
- workflow wall-clock time; orchestration overhead can fall outside the steps.
54
+ workflow wall-clock time; orchestration overhead can fall outside the steps, which is
55
+ why the total is measured separately and is usually a little larger than their sum.
52
56
 
53
57
  ## Serve the dashboard and previews
54
58
 
@@ -10,6 +10,9 @@ module Workspaces
10
10
 
11
11
  SAFE_LOG_BYTES = 8 * 1024
12
12
  SAFE_LOG_LINES = 100
13
+ # Each step shows its own tail, so the per-step bounds are smaller than the combined log's.
14
+ STEP_LOG_BYTES = 6 * 1024
15
+ STEP_LOG_LINES = 80
13
16
  DEFAULT_HOME = File.expand_path('~/.workspaces')
14
17
  DEFAULT_SUBDOMAIN_PREFIX = 'ws-'
15
18
  DEFAULT_FRONT_DOOR_PORT = 4747
@@ -5,6 +5,7 @@ require_relative 'workflow'
5
5
  require_relative 'tee_log'
6
6
  require_relative 'presentation'
7
7
  require_relative 'environment_overrides'
8
+ require_relative 'duration_format'
8
9
 
9
10
  module Workspaces
10
11
  class Coordinator
@@ -98,7 +99,9 @@ module Workspaces
98
99
  'source_update_message' => entry['source_update_message'],
99
100
  'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
100
101
  'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
101
- 'steps' => entry.fetch('steps', []),
102
+ 'steps' => entry.fetch('steps', []), 'total_seconds' => entry['total_seconds'],
103
+ 'step_logs' => include_log && log == 'setup' ? step_logs(id, entry.fetch('steps', [])) : {},
104
+ 'backend_log_available' => backend_log_available?(id),
102
105
  'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
103
106
  'environment_keys' => environment_keys(id),
104
107
  'editable_environment' => editable_environment(id),
@@ -151,7 +154,8 @@ module Workspaces
151
154
  update(id, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
152
155
  'steps' => steps,
153
156
  'operation' => restart ? 'restart' : 'prepare', 'failed_phase' => nil,
154
- 'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil)
157
+ 'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil,
158
+ 'total_seconds' => nil)
155
159
  end
156
160
 
157
161
  def spawn_worker(workspace, restart, lock)
@@ -168,28 +172,41 @@ module Workspaces
168
172
 
169
173
  def run(workspace, restart:)
170
174
  id = workspace.id
175
+ started = monotonic
171
176
  File.open(Config.setup_log_path(id), 'a') do |file|
172
177
  file.sync = true
173
178
  log = TeeLog.new(id, file)
174
179
  log.puts "==> #{restart ? 'restarting' : 'preparing'} workspace #{id}; branch and working files unchanged"
175
- prepare_and_boot(workspace, log, restart)
180
+ prepare_and_boot(workspace, log, restart, started)
176
181
  end
177
182
  rescue StandardError => e
178
- publish_error(id, e)
183
+ publish_error(id, e, started)
179
184
  end
180
185
 
181
- def prepare_and_boot(workspace, log, restart)
186
+ # The wall-clock time of the whole run, boot and readiness included. Step durations add up to
187
+ # less than this because orchestration between steps is not inside any step.
188
+ def prepare_and_boot(workspace, log, restart, started)
182
189
  id = workspace.id
183
190
  port = Workflow.new(workspace, log, state: @state).run!(restart: restart)
184
- update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port)
191
+ update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port,
192
+ 'total_seconds' => elapsed_since(started))
185
193
  end
186
194
 
187
- def publish_error(id, exception)
195
+ def publish_error(id, exception, started = nil)
188
196
  detail = scrub("#{exception.class}: #{exception.message}", id: id)
189
197
  $stdout.puts "[Workspace #{id}] ERROR: #{detail}"
190
198
  File.open(Config.setup_log_path(id), 'a') { |file| file.puts "ERROR: #{detail}" }
191
199
  update(id, 'status' => 'error', 'message' => 'Workspace preparation failed', 'last_error' => detail,
192
- 'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now)
200
+ 'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now,
201
+ 'total_seconds' => started && elapsed_since(started))
202
+ end
203
+
204
+ def monotonic
205
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
+ end
207
+
208
+ def elapsed_since(started)
209
+ (monotonic - started).round(3)
193
210
  end
194
211
 
195
212
  def locked?(id)
@@ -210,6 +227,35 @@ module Workspaces
210
227
  scrub(raw, id: id).lines.last(Config::SAFE_LOG_LINES).join
211
228
  end
212
229
 
230
+ # Output for each recipe step, keyed by step id. Only steps that have written something
231
+ # appear, so a pending step has no entry and the page can show it as not started.
232
+ def step_logs(id, steps)
233
+ steps.each_with_object({}) do |step, logs|
234
+ path = Config.step_log_path(id, step['key'], create: false)
235
+ next unless path.file?
236
+
237
+ logs[step['key']] = tail_file(path, id, bytes: Config::STEP_LOG_BYTES, lines: Config::STEP_LOG_LINES)
238
+ end
239
+ rescue ArgumentError
240
+ {}
241
+ end
242
+
243
+ def backend_log_available?(id)
244
+ path = Config.backend_log_path(id, create: false)
245
+ path.file? && !path.zero?
246
+ end
247
+
248
+ # The tail of a log file, scrubbed, with whether earlier output was cut off.
249
+ def tail_file(path, id, bytes:, lines:)
250
+ size = path.size
251
+ raw = File.open(path, 'rb') do |file|
252
+ file.seek(-bytes, IO::SEEK_END) if size > bytes
253
+ file.read
254
+ end
255
+ kept = scrub(raw, id: id).lines
256
+ { 'text' => kept.last(lines).join, 'truncated' => size > bytes || kept.length > lines }
257
+ end
258
+
213
259
  def scrub(value, id:)
214
260
  scrubbed = value.to_s
215
261
  scrubbed = EnvironmentOverrides.new(id).scrub(scrubbed)
@@ -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)
@@ -46,43 +46,131 @@
46
46
  toggleBusyState(busy);
47
47
  }
48
48
 
49
- function renderStep(step) {
50
- const item = document.createElement('li');
51
- item.className = 'step';
52
- item.dataset.stepState = step.state;
53
- if (step.state === 'active') item.setAttribute('aria-current', 'step');
54
- const bullet = document.createElement('span');
55
- bullet.className = 'step-bullet';
56
- bullet.setAttribute('aria-hidden', 'true');
57
- const label = document.createElement('span');
58
- label.textContent = step.label;
49
+ // Each recipe step is a disclosure element. The server renders the first paint; after that the
50
+ // poll updates these nodes in place, keyed by step id, so nothing the user is reading
51
+ // is rebuilt. A step opens by itself when it starts running or fails, and closes when it
52
+ // completes, but only on a state change, and never after the user toggled it by hand.
53
+ const userToggled = new Set();
54
+ const stepState = {};
55
+
56
+ function ensureStep(container, step, before) {
57
+ const existing = container.querySelector('[data-step-log="' + step.key + '"]');
58
+ if (existing) return existing;
59
+ const details = document.createElement('details');
60
+ details.className = 'step-log';
61
+ details.dataset.stepLog = step.key;
62
+ const summary = document.createElement('summary');
63
+ const status = document.createElement('span');
64
+ status.className = 'step-log__status';
65
+ status.setAttribute('aria-hidden', 'true');
66
+ const name = document.createElement('span');
67
+ name.className = 'step-log__name';
59
68
  const outcome = document.createElement('span');
60
- outcome.className = 'step-outcome';
61
- outcome.textContent = stepOutcome(step);
62
- item.append(bullet, label, outcome);
63
- return item;
69
+ outcome.className = 'step-log__outcome step-outcome';
70
+ summary.append(status, name, outcome);
71
+ const output = document.createElement('pre');
72
+ output.tabIndex = 0;
73
+ details.append(summary, output);
74
+ details.addEventListener('toggle', () => {
75
+ if (details.dataset.programmatic === 'true') return;
76
+ userToggled.add(step.key);
77
+ });
78
+ container.insertBefore(details, before || null);
79
+ return details;
80
+ }
81
+
82
+ function renderSteps(view, stepLogs) {
83
+ const container = node('steps');
84
+ if (!container) return;
85
+ const steps = view.steps || [];
86
+ const full = node('full-log');
87
+ const keep = new Set(steps.map((step) => step.key).concat(['full']));
88
+ container.querySelectorAll('[data-step-log]').forEach((element) => {
89
+ if (!keep.has(element.dataset.stepLog)) container.removeChild(element);
90
+ });
91
+ renderFullLogOutcome(view);
92
+ steps.forEach((step) => {
93
+ const details = ensureStep(container, step, full);
94
+ details.dataset.stepState = step.state;
95
+ if (step.state === 'active') details.setAttribute('aria-current', 'step');
96
+ else details.removeAttribute('aria-current');
97
+ const summary = details.querySelector('summary');
98
+ const parts = summary.children;
99
+ parts[1].textContent = step.label;
100
+ parts[2].textContent = stepOutcome(step);
101
+ const output = details.querySelector('pre');
102
+ output.setAttribute('aria-label', step.label + ' output');
103
+ const entry = stepLogs && stepLogs[step.key];
104
+ if (entry && output.textContent !== entry.text) writeLog(output, entry.text);
105
+ applyStepOpenState(details, step);
106
+ });
107
+ }
108
+
109
+ // The Full setup log row reads "in progress" while running, then how the run ended and how
110
+ // long it took. Without a recorded total it keeps its plain label.
111
+ function renderFullLogOutcome(view) {
112
+ const label = node('full-log-outcome');
113
+ if (!label) return;
114
+ const total = view.total;
115
+ const text = total && formatDuration(total.seconds);
116
+ label.textContent = view.busy
117
+ ? 'in progress'
118
+ : text
119
+ ? total.state + ' · ' + text
120
+ : 'everything, in order';
121
+ }
122
+
123
+ function applyStepOpenState(details, step) {
124
+ const previous = stepState[step.key];
125
+ stepState[step.key] = step.state;
126
+ if (previous === step.state || userToggled.has(step.key)) return;
127
+ const shouldOpen = step.state === 'active' || step.state === 'failed';
128
+ if (details.open === shouldOpen) return;
129
+ details.dataset.programmatic = 'true';
130
+ details.open = shouldOpen;
131
+ // The toggle event fires asynchronously; clear the guard after it has been delivered.
132
+ setTimeout(() => { details.dataset.programmatic = 'false'; }, 0);
133
+ }
134
+
135
+ // Writing a log must not yank a reader who scrolled up; follow the tail only if already there.
136
+ function writeLog(pre, text) {
137
+ const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 18;
138
+ pre.textContent = text || '';
139
+ if (atBottom) pre.scrollTop = pre.scrollHeight;
140
+ }
141
+
142
+ // Durations read the way people say them: 0.5s, 12.3s, 1m 10s, 1h 5m 3s. This mirrors
143
+ // Workspaces::DurationFormat in Ruby; a spec runs both over the same inputs.
144
+ function formatDuration(seconds) {
145
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return null;
146
+ if (seconds < 60) {
147
+ const rounded = Math.round(seconds * 10) / 10;
148
+ if (rounded >= 60) return '1m 0s';
149
+ return rounded + 's';
150
+ }
151
+ const whole = Math.round(seconds);
152
+ const hours = Math.floor(whole / 3600);
153
+ const minutes = Math.floor((whole % 3600) / 60);
154
+ const secs = whole % 60;
155
+ return hours > 0 ? hours + 'h ' + minutes + 'm ' + secs + 's' : minutes + 'm ' + secs + 's';
64
156
  }
65
157
 
66
158
  function stepOutcome(step) {
67
159
  const state = step.state;
68
- const rawDuration = step.duration_seconds;
69
- if (typeof rawDuration !== 'number') return state;
70
- const duration = rawDuration;
71
- if (
72
- (state === 'complete' || state === 'failed') &&
73
- Number.isFinite(duration) &&
74
- duration >= 0
75
- )
76
- return state + ' · ' + duration.toFixed(3) + 's';
77
- return state;
160
+ if (state !== 'complete' && state !== 'failed') return state;
161
+ const text = formatDuration(step.duration_seconds);
162
+ return text ? state + ' · ' + text : state;
78
163
  }
79
164
 
80
165
  function updateLog(data) {
81
166
  if (data.log_source !== selectedLog) return;
82
- const log = node('log');
83
- const bottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 18;
84
- log.textContent = data.log_tail || '';
85
- if (bottom) log.scrollTop = log.scrollHeight;
167
+ const tab = node('backend-tab');
168
+ if (tab && data.backend_log_available) tab.hidden = false;
169
+ if (selectedLog === 'backend') writeLog(node('log'), data.log_tail);
170
+ else {
171
+ const full = node('full-log');
172
+ if (full) writeLog(full.querySelector('pre'), data.log_tail);
173
+ }
86
174
  node('updated').textContent = data.updated_at || '';
87
175
  }
88
176
 
@@ -95,9 +183,9 @@
95
183
  completed = Date.parse(data.completed_at);
96
184
  renderStatus(view);
97
185
  renderSections(view);
98
- node('message').textContent = data.message || '';
186
+ if (node('message')) node('message').textContent = data.message || '';
99
187
  node('source-update-message').textContent = data.source_update_message || '';
100
- node('progress').replaceChildren(...view.steps.map(renderStep));
188
+ renderSteps(view, data.step_logs);
101
189
  renderActions(view.actions);
102
190
  if (window.WorkspaceEnvironment)
103
191
  window.WorkspaceEnvironment.syncFromSnapshot(data);
@@ -109,25 +197,74 @@
109
197
  root.querySelectorAll('[data-section]').forEach((element) => {
110
198
  element.hidden = !sections[element.dataset.section];
111
199
  });
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';
200
+ renderLead(view);
201
+ applyDisclosureState(view);
124
202
  if (window.WorkspaceEnvironment)
125
203
  window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
126
204
  }
127
205
 
206
+ function renderLead(view) {
207
+ node('status').textContent = view.headline || view.title;
208
+ node('summary').textContent = view.summary || '';
209
+ const subtitle = node('subtitle');
210
+ const activity = node('activity');
211
+ if (subtitle) {
212
+ const text = view.subtitle || '';
213
+ subtitle.hidden = !text;
214
+ // The text has its own element, so it can be set whether or not the server rendered any.
215
+ const target = node('subtitle-text');
216
+ if (target) target.textContent = text;
217
+ }
218
+ if (activity) activity.hidden = !view.busy;
219
+ const title = node('environment-title');
220
+ if (title && view.environment_title) title.textContent = view.environment_title;
221
+ const badge = node('environment-preset-badge');
222
+ if (badge) {
223
+ badge.hidden = !view.current_preset;
224
+ badge.textContent = view.current_preset ? 'Preset: ' + view.current_preset : '';
225
+ }
226
+ renderLeadAction(view.lead_action);
227
+ }
228
+
229
+ // The lead action is a lifecycle post (start/retry) or, when ready, the preview link
230
+ // rendered separately as #preview. Rebuild only when it actually changes.
231
+ let leadSignature = null;
232
+ function renderLeadAction(action) {
233
+ const holder = node('lead-action');
234
+ if (!holder) return;
235
+ const signature = JSON.stringify(action || null);
236
+ if (signature === leadSignature) return;
237
+ leadSignature = signature;
238
+ holder.replaceChildren();
239
+ if (!action || action.kind !== 'post' || !allowedActions.includes(action.key)) return;
240
+ const form = document.createElement('form');
241
+ form.method = 'post';
242
+ form.action = base + '/' + action.key;
243
+ const button = document.createElement('button');
244
+ button.className = 'button button--primary button--large';
245
+ button.textContent = action.label;
246
+ form.append(button);
247
+ holder.append(form);
248
+ toggleBusyState(busy);
249
+ }
250
+
251
+ // A disclosure opens by itself when its state says so (a failure must never be hidden,
252
+ // a fresh workspace needs its settings), but we never close one the user opened, and
253
+ // we only apply the state's preference when the state changes, not on every poll.
254
+ let lastDisclosureState = root.dataset.disclosureState || null;
255
+ function applyDisclosureState(view) {
256
+ if (view.state === lastDisclosureState) return;
257
+ lastDisclosureState = view.state;
258
+ const open = view.open || {};
259
+ [['environment-disclosure', open.environment], ['technical-disclosure', open.technical]].forEach(
260
+ ([id, shouldOpen]) => {
261
+ const element = node(id);
262
+ if (element) element.open = !!shouldOpen;
263
+ }
264
+ );
265
+ }
266
+
128
267
  function renderStatus(view) {
129
- node('status').textContent = view.title;
130
- node('activity').hidden = !view.busy;
131
268
  node('preview').hidden = !view.ready;
132
269
  node('error').textContent = view.error || '';
133
270
  node('error-panel').hidden = !view.error;
@@ -159,9 +296,8 @@
159
296
  if (Number.isFinite(started)) {
160
297
  const end = busy ? Date.now() : completed;
161
298
  const seconds = Math.max(0, Math.floor((end - started) / 1000));
162
- if (Number.isFinite(seconds))
163
- node('elapsed').textContent =
164
- Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
299
+ // The running clock counts whole seconds, so format it as one (no decimals).
300
+ if (Number.isFinite(seconds)) node('elapsed').textContent = formatDuration(seconds);
165
301
  }
166
302
  setTimeout(tick, 1000);
167
303
  }