workspaces 0.1.19 → 0.1.20

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: 1663319f23323ff66ace4b0744db8bd152448d78ed38d164299bf3c8e641ef8d
4
- data.tar.gz: 44dea1893b4fc1299a7bb2c064b302e556b3574baebd2b7215fad8747c6b80c6
3
+ metadata.gz: b62d0530751f975a4e8604a691dce7bc133c3d7bd57a966e08e9b9e24c7088d9
4
+ data.tar.gz: 3fb3b5acff924444694d1390b9edd0f4dc2eaf5885da24b938552180b52a0b0e
5
5
  SHA512:
6
- metadata.gz: c00c46b1ac312ae72bb239d74da78fe7c8b4094e0a04d6ae0ebf44232df1050c63f0d5c8a15232daef42d7b9ba526ca71cf86c70731728878db936d98a8b939d
7
- data.tar.gz: d285c6a6d3f77f94da739295120d7538babc59de94c37fa538eeb0e49c6be6eaefaefe72e2524b9ad448a41032e676fa57f445d359adf15a0bdeba55046ccecc
6
+ metadata.gz: e860375ef8492aba63ef9340ff2ad29e22fc504353546d343b3e2c3dc31833b9df5bc77de4a8b367eeeb8e3423aef5918e0f61a51d4de26dd8ea9d916af7bec3
7
+ data.tar.gz: d8521f83fd632951c5ac551f52f1c4a72db8d22afd33dca483bad10e1f82456f01d8fcf4fa0d50fc00e93cb98da32eab34ae6c16454a8cb931e5654c9349ee19
data/DESIGN.md CHANGED
@@ -38,7 +38,7 @@ Restrained tooling UI: a near-black page, one slightly lighter card surface with
38
38
  ## Component primitives
39
39
 
40
40
  - **Creation form**: four labelled fields in one wrapping row (`flex: 1 1 220px`, 16px gap), each with a real `<label for>`, then a `type="submit"` button on its own line.
41
- - **Workspace inventory**: a centered column up to 1180px. The page header carries "N managed worktrees · none running" (or "· K running"), counted from the data. The table sits in an `overflow-x: auto` wrapper with `min-width: 820px`, so a phone scrolls it sideways instead of cramming or hiding columns. Rows have 16px padding; workspace names are monospaced links with no underline until hover. Times read "Oct 27, 10:48", are UTC (the headers say so) and keep the full ISO value in a `title`. A missing start reads "Never" and a missing creation time "Unavailable", in the muted colour.
41
+ - **Workspace inventory**: a centered column up to 1180px. Rows are ordered by last startup, newest first; workspaces that never started (or whose start time cannot be read) come last, and ties fall back to the id so the list never shuffles between loads. Moments are compared as moments, not text. The page header carries "N managed worktrees · none running" (or "· K running"), counted from the data. The table sits in an `overflow-x: auto` wrapper with `min-width: 820px`, so a phone scrolls it sideways instead of cramming or hiding columns. Rows have 16px padding; workspace names are monospaced links with no underline until hover. Times read "Oct 27, 10:48", are UTC (the headers say so) and keep the full ISO value in a `title`. A missing start reads "Never" and a missing creation time "Unavailable", in the muted colour.
42
42
  - **Status pill**: a dot and one word (13px, 600, fully rounded), never colour alone. A busy workspace adds "(busy)" and pulses its dot, which stops under reduced motion.
43
43
  - **Setup progress**: names and ordered states come from repository run steps; failures are highlighted. Restart shows the background launch and later readiness commands.
44
44
  - **Actions by state**: idle/stopped offers Start; active operations show progress without lifecycle buttons; failure offers Retry; ready offers Open workspace plus rebuild/restart/stop.
@@ -49,18 +49,18 @@ Restrained tooling UI: a near-black page, one slightly lighter card surface with
49
49
  - **Shared with the settings page**: the card reuses the page's element ids and `app.js`, so polling needs no second script. `app.js` tolerates the elements the card lacks (actions, steps, logs, environment).
50
50
  - **On a preview host** (`data-base="/__workspaces"`, `data-reload-when-ready="true"`): the card is served from the workspace's own origin, so it is the smallest surface that can work. It polls `/__workspaces/status`, posts only to `/__workspaces/start|prepare|restart|resume`, shows a fixed failure line instead of error text, and reloads the current page once when the workspace is ready. Everything else (settings, environment, secrets, logs, removal) stays on the shared dashboard origin behind its same-origin check.
51
51
  - **Settings page**: the full page described below, with a *Back to workspace* link. It is where Update from PR, keep-awake, rebuild, restart, stop, environment editing and deletion live.
52
- - **Three layers** (the settings page; the card shows only the lead): 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
+ - **Three layers** (the settings page; the card shows only the lead): the workspace page leads with the friendly, the deliberate, then the technical. Which layers are shown depends on lifecycle state, driven by `Presentation::STATES`. They are plain sections, never folded: the card at `/workspaces/ID` is what keeps the details out of the way, so the settings page has nothing to hide.
53
53
  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.
54
- 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.
55
- 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.
54
+ 2. **Settings** (a section headed *Change settings*, with a *Preset: name* badge beside the heading whenever the saved settings exactly match a preset, 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).
55
+ 3. **Technical details** (a section): 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 is shown whenever the state has something to show (while setup runs, after a failure, and once ready), so a cause is never hidden.
56
56
  - **Lead by state**:
57
- - `idle`: "Let's get your workspace running", with the settings open and *Start workspace* in the form.
58
- - `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.
59
- - `ready`: "Your workspace is ready" with a large **Open workspace** button. Everything else is collapsed.
57
+ - `idle`: "Let's get your workspace running", with the settings shown and *Start workspace* in the form.
58
+ - `busy`: "Setting things up…", with the current step as a quiet line and the elapsed time. Technical details are shown so progress and the log can be watched.
59
+ - `ready`: "Your workspace is ready" with a large **Open workspace** button. Settings and technical details sit below it.
60
60
  - `stopped`: "Your workspace is paused" with a large **Start workspace** button.
61
- - `error`/`interrupted`: a plain "Something didn't go to plan" with a large **Retry failed step** button (**Continue setup** after an interruption) that names the step in the summary, a subtle **Re-run all steps** beside it, the error shown, and technical details open. When the recorded steps cannot be resumed, the single **Retry** of the earlier design is shown instead.
62
- - `unknown`: a minimal view with technical details open.
63
- - **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.
61
+ - `error`/`interrupted`: a plain "Something didn't go to plan" with a large **Retry failed step** button (**Continue setup** after an interruption) that names the step in the summary, a subtle **Re-run all steps** beside it, the error shown, and technical details shown. When the recorded steps cannot be resumed, the single **Retry** of the earlier design is shown instead.
62
+ - `unknown`: a minimal view with technical details shown.
63
+ - **Section visibility**: the server renders which sections the state shows, and the polling script reveals or hides them as the state changes. Sections that are not meaningful in a state are hidden, never shown disabled. Nothing on the page folds except the per-step log rows below, so polling never opens or closes anything the reader is looking at.
64
64
  - **Per-step re-run controls**: inside each step's accordion, small subtle **Re-run this step** and **Re-run from here** forms aimed at `/workspaces/ID/resume?only=KEY` and `?from=KEY`. The server decides which appear (`presentation.resume.step_modes`), so the page never offers something that would be refused; they are hidden while a run is in progress and are disabled with the other controls while busy.
65
65
  - **Sleeping**: a workspace asleep is its own state (`sleeping`), distinct from stopped and failed. The lead reads "Your workspace is asleep", says files and data are kept and that opening the preview wakes it, and offers one **Wake workspace** button. A ready workspace shows one muted line, "Sleeps after N min without preview traffic", or "Kept awake" when exempt. **Keep awake** / **Allow sleep** are subtle workspace controls. A waking workspace is simply busy.
66
66
  - **Environment rows**: the settings form is a list of rows, each a name input, a value input and a **Secret** checkbox, with **Add variable** and a per-row **Remove** (both revealed by script; without it the server-rendered rows still save). Rows post as `env[N][name|value|secret]`. A secret row's value is a password field that is never pre-filled; a stored secret shows its name, the placeholder "Stored. Enter a new value to replace it", and a locked checked checkbox (a hidden field carries it), so it can only be replaced or removed, not exposed. Ticking Secret on a new row switches its value to a password field. Presets replace only the visible rows and are matched against visible rows alone. A note under the rows shows why secrets are unavailable when the vault is not usable, and a line explains that secrets are write-only.
@@ -70,7 +70,7 @@ Restrained tooling UI: a near-black page, one slightly lighter card surface with
70
70
  - **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.
71
71
  - **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.
72
72
  - **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`.
73
- - **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.
73
+ - **Tabs**: *Setup steps* (the step rows) and *Web server log*. The server tab appears only once the backend log has content. Switching tabs reloads the page, and the links end in `#log-title` so the reader lands back on the log rather than the top of the page. The combined log stays reachable as a final, collapsed *Full setup log* row, which also covers workspaces set up before step files existed.
74
74
  - **Build progress**: while a build is being prepared the lead is the headline, a progress bar (`role="progressbar"` with a text value), and one line saying *Step n of m · name*, the percentage, the elapsed time and an estimate. The server computes the numbers from the step states and per-step averages frozen when the run began (`Progress`), and the script only draws them; the elapsed clock ticks locally between polls. A step well past its usual time turns the bar amber and the estimate reads "Taking longer than usual". Without history there is no time estimate. The bar is hidden when no build is running.
75
75
  - **Build history**: every finished run is appended to `build-history.jsonl` (`BuildHistory`). Estimates are the mean of a step's last 10 successful completions; failed runs are recorded but never counted. Recording is best-effort and cannot fail a build.
76
76
  - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
@@ -244,7 +244,6 @@
244
244
  element.hidden = !sections[element.dataset.section];
245
245
  });
246
246
  renderLead(view);
247
- applyDisclosureState(view);
248
247
  if (window.WorkspaceEnvironment)
249
248
  window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
250
249
  }
@@ -325,22 +324,6 @@
325
324
  toggleBusyState(busy);
326
325
  }
327
326
 
328
- // A disclosure opens by itself when its state says so (a failure must never be hidden,
329
- // a fresh workspace needs its settings), but we never close one the user opened, and
330
- // we only apply the state's preference when the state changes, not on every poll.
331
- let lastDisclosureState = root.dataset.disclosureState || null;
332
- function applyDisclosureState(view) {
333
- if (view.state === lastDisclosureState) return;
334
- lastDisclosureState = view.state;
335
- const open = view.open || {};
336
- [['environment-disclosure', open.environment], ['technical-disclosure', open.technical]].forEach(
337
- ([id, shouldOpen]) => {
338
- const element = node(id);
339
- if (element) element.open = !!shouldOpen;
340
- }
341
- );
342
- }
343
-
344
327
  // A card on a preview host stands in for the page the visitor asked for; reloading brings them to it.
345
328
  let reloading = false;
346
329
  function reloadWhenReady(view) {
@@ -1,4 +1,4 @@
1
- <main class="card-page" id="workspace" data-id="<%= @id %>" data-base="<%= action_base %>" data-reload-when-ready="<%= @preview ? 'true' : 'false' %>" 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']) %>">
1
+ <main class="card-page" id="workspace" data-id="<%= @id %>" data-base="<%= action_base %>" data-reload-when-ready="<%= @preview ? 'true' : 'false' %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>">
2
2
  <section class="card" aria-labelledby="status">
3
3
  <header class="card__header">
4
4
  <p class="card__name"><span class="eyebrow">Workspace</span><span class="card__id"><%= @id %></span></p>
@@ -205,48 +205,20 @@ h1 {
205
205
  .lead__action:empty { display: none; }
206
206
  .button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius-control); }
207
207
 
208
- /* Disclosures: quiet by default, a clear label and a one-line hint of what is inside. */
209
- /* A disclosure has to read as a control. The native marker is lost once a summary is a flex
210
- container, so draw our own chevron; the whole header is the click target, with a hover
211
- fill and a keyboard focus ring. */
212
- /* Two classes so the narrow-screen panel padding below cannot re-add padding around the summary. */
213
- .status-panel.disclosure { padding: 0; }
214
- .disclosure > summary {
208
+ /* Settings page sections: a heading, a one-line hint of what is inside, then the content. Nothing
209
+ folds; the card at /workspaces/ID is what keeps these details out of the way. The section is a grid
210
+ (.status-panel:not(details)), so the gap below the heading comes from there. */
211
+ .section-heading {
215
212
  display: flex;
216
213
  flex-wrap: wrap;
217
214
  align-items: baseline;
218
215
  gap: var(--space-1) var(--space-3);
219
- padding: var(--space-4) calc(var(--space-6) + var(--space-8)) var(--space-4) var(--panel-padding);
220
- position: relative;
221
- list-style: none;
222
- color: var(--text);
223
- font-weight: 600;
224
- border-radius: var(--radius);
225
- cursor: pointer;
226
- user-select: none;
227
- transition: background-color 120ms ease;
216
+ padding-block-end: var(--space-4);
217
+ border-block-end: 1px solid var(--border);
228
218
  }
229
- .disclosure > summary::-webkit-details-marker { display: none; }
230
- .disclosure > summary::after {
231
- content: '';
232
- position: absolute;
233
- inset-inline-end: var(--panel-padding);
234
- inset-block-start: 50%;
235
- inline-size: 8px;
236
- block-size: 8px;
237
- margin-block-start: -6px;
238
- border-inline-end: 2px solid var(--muted);
239
- border-block-end: 2px solid var(--muted);
240
- transform: rotate(45deg);
241
- transition: transform 160ms ease, border-color 120ms ease;
242
- }
243
- .disclosure[open] > summary::after { margin-block-start: -2px; transform: rotate(225deg); }
244
- .disclosure > summary:hover { background: color-mix(in oklab, var(--accent) 8%, transparent); }
245
- .disclosure > summary:hover::after { border-color: var(--text); }
246
- .disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
247
- .disclosure[open] > summary { border-end-start-radius: 0; border-end-end-radius: 0; border-block-end: 1px solid var(--border); margin-block-end: 0; }
248
- .disclosure > :not(summary) { margin: var(--panel-padding); }
249
- /* Which preset is active, readable without opening the section. Same tint as a pressed preset button. */
219
+ .section-heading h2 { margin: 0; font-size: 1.125rem; }
220
+ .section-heading p { margin: 0; font-size: 13px; }
221
+ /* Which preset is active, readable at a glance. Same tint as a pressed preset button. */
250
222
  .badge {
251
223
  display: inline-flex;
252
224
  align-items: center;
@@ -262,14 +234,12 @@ h1 {
262
234
  white-space: nowrap;
263
235
  }
264
236
  .badge::before { content: ''; inline-size: 6px; block-size: 6px; border-radius: 999px; background: var(--success); }
265
- .disclosure__hint { color: var(--muted); font-size: 13px; font-weight: 400; }
266
- @media (prefers-reduced-motion: reduce) { .disclosure > summary, .disclosure > summary::after { transition: none; } }
267
- .disclosure .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
268
- .disclosure .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
269
- .disclosure .log-panel { padding: 0; }
237
+ #technical-section .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
238
+ #technical-section .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
239
+ #technical-section .log-panel { padding: 0; }
270
240
  .log-panel > #log { margin-block-start: 0; }
271
- .disclosure .danger-zone h3 { color: var(--danger); font-size: 1rem; }
272
- .disclosure h3 { font-size: 1rem; }
241
+ #technical-section .danger-zone h3 { color: var(--danger); font-size: 1rem; }
242
+ #technical-section h3 { font-size: 1rem; }
273
243
 
274
244
  .status-row {
275
245
  display: grid;
@@ -1,4 +1,4 @@
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']) %>">
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
3
  <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1 class="mono-title"><%= @id %></h1></div>
4
4
  <div class="header-actions"><a class="button button--subtle" href="/workspaces/<%= @id %>">Back to workspace</a><a class="button button--subtle" href="/workspaces">All workspaces</a></div>
@@ -26,9 +26,9 @@
26
26
  </section>
27
27
  <p id="connection" role="status"></p>
28
28
  </section>
29
- <details class="status-panel disclosure" id="environment-disclosure" data-section="environment" <%= 'open' if @presentation.dig('open', 'environment') %> <%= 'hidden' unless section?('environment') %>>
30
- <summary><span id="environment-title"><%= escape(@presentation['environment_title']) %></span><span class="badge" id="environment-preset-badge" <%= 'hidden' unless @presentation['current_preset'] %>><%= escape(preset_badge_text) %></span><span class="disclosure__hint">Presets and environment variables</span></summary>
31
- <div class="workspace-settings" aria-labelledby="environment-title">
29
+ <section class="status-panel" id="environment-section" data-section="environment" aria-labelledby="environment-title" <%= 'hidden' unless section?('environment') %>>
30
+ <header class="section-heading"><h2 id="environment-title"><%= escape(@presentation['environment_title']) %></h2><span class="badge" id="environment-preset-badge" <%= 'hidden' unless @presentation['current_preset'] %>><%= escape(preset_badge_text) %></span><p class="muted">Presets and environment variables</p></header>
31
+ <div class="workspace-settings">
32
32
  <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
33
33
  <fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
34
34
  <legend class="env-legend">Workspace settings</legend>
@@ -66,9 +66,9 @@
66
66
  <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
67
67
  </form>
68
68
  </div>
69
- </details>
70
- <details class="status-panel disclosure" id="technical-disclosure" data-section="technical" <%= 'open' if @presentation.dig('open', 'technical') %> <%= 'hidden' unless section?('technical') %>>
71
- <summary><span>Technical details</span><span class="disclosure__hint">Setup steps, logs and workspace controls</span></summary>
69
+ </section>
70
+ <section class="status-panel" id="technical-section" data-section="technical" aria-labelledby="technical-title" <%= 'hidden' unless section?('technical') %>>
71
+ <header class="section-heading"><h2 id="technical-title">Technical details</h2><p class="muted">Setup steps, logs and workspace controls</p></header>
72
72
  <div class="technical stack stack--loose">
73
73
  <div class="workspace-actions-group stack stack--snug" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
74
74
  <h3 id="actions-heading">Workspace controls</h3>
@@ -80,8 +80,8 @@
80
80
  <section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
81
81
  <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>
82
82
  <nav class="log-switch" aria-label="Log source">
83
- <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup steps</a>
84
- <a href="?log=backend" id="backend-tab" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %> <%= 'hidden' unless backend_tab? %>>Web server log</a>
83
+ <a href="?log=setup#log-title" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup steps</a>
84
+ <a href="?log=backend#log-title" id="backend-tab" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %> <%= 'hidden' unless backend_tab? %>>Web server log</a>
85
85
  </nav>
86
86
  <div id="steps" class="step-logs" data-step-logs <%= 'hidden' if @snapshot['log_source'] == 'backend' %>>
87
87
  <% @presentation['steps'].each do |step| %><%= step_accordion(step) %><% end %>
@@ -107,6 +107,6 @@
107
107
  </form>
108
108
  </section>
109
109
  </div>
110
- </details>
110
+ </section>
111
111
  <noscript><a href="/workspaces/<%= @id %>/settings">Refresh status</a></noscript>
112
112
  </main>
@@ -135,13 +135,31 @@ module Workspaces
135
135
  <table class="workspace-table">
136
136
  <thead><tr><th scope="col">Workspace</th><th scope="col">Status</th><th scope="col" class="workspace-table__date">Created (UTC)</th><th scope="col" class="workspace-table__date">Last startup (UTC)</th></tr></thead>
137
137
  <tbody>
138
- #{workspaces.map { |workspace| inventory_row(workspace) }.join}
138
+ #{by_last_startup(workspaces).map { |workspace| inventory_row(workspace) }.join}
139
139
  </tbody>
140
140
  </table>
141
141
  </div>
142
142
  HTML
143
143
  end
144
144
 
145
+ # Most recently started first; workspaces that have never started (or whose start time cannot be read)
146
+ # come last. Moments are compared as moments, not as text, so a different UTC offset or fraction cannot
147
+ # misorder rows, and ties fall back to the id so the list never shuffles between loads.
148
+ def by_last_startup(workspaces)
149
+ workspaces.sort_by do |workspace|
150
+ started = startup_moment(workspace['started_at'])
151
+ [started ? 0 : 1, started ? -started.to_r : 0, workspace['id'].to_s]
152
+ end
153
+ end
154
+
155
+ def startup_moment(value)
156
+ return nil if value.nil? || value.to_s.empty?
157
+
158
+ Time.iso8601(value.to_s)
159
+ rescue ArgumentError
160
+ nil
161
+ end
162
+
145
163
  # "8 managed worktrees · none running", counted from the data on the page.
146
164
  def inventory_summary(workspaces)
147
165
  total = workspaces.length
@@ -11,12 +11,12 @@ module Workspaces
11
11
  'resume' => 'Retry failed step', 'keep-awake' => 'Keep awake',
12
12
  'allow-sleep' => 'Allow sleep' }.freeze
13
13
 
14
- # Three layers, from friendly to technical. `lead` is the headline, one sentence and at
15
- # most one primary action. `environment` (presets and variables) is the area for
16
- # deliberately changing something. `technical` is one disclosure holding setup steps,
17
- # logs, workspace details, lifecycle controls and deletion; it is never the first thing
18
- # a user reads, but it opens by itself when something needs attention. Setup steps are
19
- # not a section of their own: each is an accordion with its own log inside `log`.
14
+ # The sections of the settings page, from friendly to technical. `lead` is the headline, one
15
+ # sentence and at most one primary action. `environment` (presets and variables) is the area for
16
+ # deliberately changing something. `technical` holds setup steps, logs, workspace details,
17
+ # lifecycle controls and deletion. All of them are plain sections, always open when shown: the
18
+ # card at /workspaces/ID is what keeps the details out of the way, so this page has nothing to
19
+ # fold. Setup steps are not a section of their own: each has its own log row inside `log`.
20
20
  SECTIONS = %w(lead actions environment technical log details danger).freeze
21
21
  TECHNICAL = %w(technical actions log details danger).freeze
22
22
 
@@ -32,66 +32,57 @@ module Workspaces
32
32
  # `environment_submit` labels the single environment-form button, which saves the
33
33
  # settings and (re)starts the workspace, so a brand new workspace needs only that.
34
34
  #
35
- # Per state: `headline` and `summary` are plain language; `open_environment` and
36
- # `open_technical` say which disclosures start expanded. A failure always opens the
37
- # technical section so the cause is visible without hunting for it.
35
+ # Per state: `headline` and `summary` are plain language, `sections` says which sections are
36
+ # shown, and `environment_submit` / `environment_title` word the settings form.
38
37
  STATES = {
39
38
  'idle' => {
40
39
  sections: %w(lead environment),
41
40
  headline: "Let's get your workspace running",
42
41
  summary: 'Pick the version of the site you want to preview, then start. Setup usually takes a few minutes.',
43
- environment_submit: 'Start workspace', environment_title: 'Choose what to preview',
44
- open_environment: true, open_technical: false
42
+ environment_submit: 'Start workspace', environment_title: 'Choose what to preview'
45
43
  },
46
44
  'busy' => {
47
45
  sections: %w(lead technical log),
48
46
  headline: 'Setting things up…',
49
47
  summary: 'This page keeps itself up to date. Open the preview once it is ready.',
50
- environment_submit: 'Save & restart', environment_title: 'Change settings',
51
- open_environment: false, open_technical: true
48
+ environment_submit: 'Save & restart', environment_title: 'Change settings'
52
49
  },
53
50
  'ready' => {
54
51
  sections: %w(lead environment) + TECHNICAL,
55
52
  headline: 'Your workspace is ready',
56
53
  summary: 'Everything is set up and running. Open it to see your changes.',
57
- environment_submit: 'Save & restart', environment_title: 'Change settings',
58
- open_environment: false, open_technical: false
54
+ environment_submit: 'Save & restart', environment_title: 'Change settings'
59
55
  },
60
56
  'sleeping' => {
61
57
  sections: %w(lead environment) + TECHNICAL,
62
58
  headline: 'Your workspace is asleep',
63
59
  summary: 'It went quiet, so its server was stopped to free up resources. Your files and data are kept. ' \
64
60
  'Opening the preview wakes it in a few seconds.',
65
- environment_submit: 'Save & start', environment_title: 'Change settings',
66
- open_environment: false, open_technical: false
61
+ environment_submit: 'Save & start', environment_title: 'Change settings'
67
62
  },
68
63
  'stopped' => {
69
64
  sections: %w(lead environment) + TECHNICAL,
70
65
  headline: 'Your workspace is paused',
71
66
  summary: 'Start it again to pick up where you left off.',
72
- environment_submit: 'Save & start', environment_title: 'Change settings',
73
- open_environment: false, open_technical: false
67
+ environment_submit: 'Save & start', environment_title: 'Change settings'
74
68
  },
75
69
  'error' => {
76
70
  sections: %w(lead environment) + TECHNICAL,
77
71
  headline: "Something didn't go to plan",
78
72
  summary: 'Setup stopped before it finished. You can try again, or change settings first.',
79
- environment_submit: 'Save & retry', environment_title: 'Change settings',
80
- open_environment: false, open_technical: true
73
+ environment_submit: 'Save & retry', environment_title: 'Change settings'
81
74
  },
82
75
  'interrupted' => {
83
76
  sections: %w(lead environment) + TECHNICAL,
84
77
  headline: 'Setup was interrupted',
85
78
  summary: 'It stopped before it finished. Try again to pick up from the start.',
86
- environment_submit: 'Save & retry', environment_title: 'Change settings',
87
- open_environment: false, open_technical: true
79
+ environment_submit: 'Save & retry', environment_title: 'Change settings'
88
80
  },
89
81
  'unknown' => {
90
82
  sections: %w(lead technical log details),
91
83
  headline: 'Workspace status unknown',
92
84
  summary: 'Open the technical details to see what the workspace is doing.',
93
- environment_submit: 'Save & restart', environment_title: 'Change settings',
94
- open_environment: false, open_technical: true
85
+ environment_submit: 'Save & restart', environment_title: 'Change settings'
95
86
  }
96
87
  }.freeze
97
88
 
@@ -107,8 +98,7 @@ module Workspaces
107
98
  'lead_action' => lead_action, 'total' => total, 'progress' => progress,
108
99
  'current_preset' => current_preset, 'sleep' => sleep_info,
109
100
  'environment_title' => definition[:environment_title],
110
- 'environment_submit' => definition[:environment_submit],
111
- 'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
101
+ 'environment_submit' => definition[:environment_submit] }
112
102
  end
113
103
 
114
104
  private
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.19'
2
+ VERSION = '0.1.20'
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.19
4
+ version: 0.1.20
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-28 00:00:00.000000000 Z
11
+ date: 2026-10-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack