@monster-orange/den 0.4.9 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.11] - 2026-11-07
8
+
9
+ ### Changed
10
+
11
+ - **Picking a workspace in the top bar also makes it the CLI default.**
12
+ There is one "current workspace": choosing one in the Workspace menu
13
+ sets what pages show and where `<cli>` commands run (workspaces.yml, as
14
+ Local Dev's "Make CLI default" does). "All active (n)" changes only what
15
+ pages show; the CLI default stays. If the CLI default can't be set,
16
+ nothing changes and the error shows.
17
+ - **The menu's separate "Make <ws> the CLI default" item is gone**, since
18
+ picking a workspace does it. The "CLI default" tag stays on its row, and
19
+ Local Dev keeps its "Make CLI default" button, to set it without
20
+ changing what pages show. Each workspace row's tooltip says so.
21
+ - **Picking the workspace already in view makes it the CLI default** when
22
+ it isn't one (after Local Dev's "Make CLI default" or `<cli> workspace
23
+ focus` moved it). The view stays as it is.
24
+ - **Pages whose address names a workspace follow the pick.** Workspaces'
25
+ detail pane and breadcrumb now show the workspace the top bar picked,
26
+ not the one in the old `?ws=`.
27
+
28
+ ### Migrations
29
+
30
+ None.
31
+
32
+ ## [0.4.10] - 2026-11-07
33
+
34
+ ### Changed
35
+
36
+ - **A confirmed merge stays confirmed.** Once `gh pr view` says an agent's
37
+ pull request is MERGED, its record keeps that (`prMergeConfirmed`), and
38
+ `usage` doesn't ask GitHub about it again. A later run that can't check
39
+ no longer drops it. A merged flag from before 0.4.10, with no
40
+ confirmation, is checked again.
41
+ - **`usage agents` counts only real asks.** "Asked GitHub for the state of
42
+ N pull requests" counts the times gh ran. When gh is not installed, it
43
+ says "Couldn't check N merges: gh is not installed." instead.
44
+ - **The PR column** shows the confirmed pull request, with "(merged)", when
45
+ the agent merged one it didn't open.
46
+ - **A bare `gh pr merge`** (the branch's own pull request) is confirmed by
47
+ the number gh gives: the check is now `gh pr view --json number,state`.
48
+ With no number, it is not marked; an empty ref is never a key.
49
+ - **Dropping a mark drops its rotation.** When gh no longer confirms a
50
+ merged flag, a "PR merged" rotation it made due is cleared too. A
51
+ rotation due after compactions is left alone.
52
+
53
+ ### Migrations
54
+
55
+ None.
56
+
7
57
  ## [0.4.9] - 2026-11-07
8
58
 
9
59
  A session ends when its pull request merges, or after 3 compactions. The
package/README.md CHANGED
@@ -10,8 +10,8 @@
10
10
  </p>
11
11
 
12
12
  <picture>
13
- <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.9/hero-dark.png">
14
- <img src="docs/images/0.4.9/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
13
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.11/hero-dark.png">
14
+ <img src="docs/images/0.4.11/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
15
15
  </picture>
16
16
 
17
17
  ## Set up by your agent
@@ -38,7 +38,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
38
38
  yours. Den connects them and turns on good practice from the start.
39
39
 
40
40
  <p align="center">
41
- <img src="docs/images/0.4.9/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
41
+ <img src="docs/images/0.4.11/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
42
42
  </p>
43
43
 
44
44
  ## How it helps
@@ -68,10 +68,10 @@ yours. Den connects them and turns on good practice from the start.
68
68
  lives in your own repo.
69
69
 
70
70
  <p align="center">
71
- <img src="docs/images/0.4.9/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
72
- <img src="docs/images/0.4.9/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
73
- <img src="docs/images/0.4.9/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
74
- <img src="docs/images/0.4.9/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
71
+ <img src="docs/images/0.4.11/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
72
+ <img src="docs/images/0.4.11/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
73
+ <img src="docs/images/0.4.11/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
74
+ <img src="docs/images/0.4.11/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
75
75
  </p>
76
76
 
77
77
  ## Get started
@@ -2482,7 +2482,7 @@ report. It works on den-claude for personas with Den's hooks on.
2482
2482
  A merge is never taken from text. The agent must have run `gh pr merge`
2483
2483
  as a command (after any `cd` or `VAR=value` prefix; not `--help`, not
2484
2484
  `echo gh pr merge`), and it must have exited 0. Then `usage` asks
2485
- `gh pr view <ref> --json state` in the agent's folder, and only
2485
+ `gh pr view <ref> --json number,state` in the agent's folder, and only
2486
2486
  `MERGED` counts. With no gh, or on any error, it is not marked. That
2487
2487
  query is the one thing `usage` asks outside this machine; it says so
2488
2488
  when it does.
@@ -2762,9 +2762,13 @@ and its wall-clock time. It matches transcripts to agents by the
2762
2762
  conversation id Den saved at start. It prints the share of agents with
2763
2763
  both. Transcripts never leave the machine, and their text is never kept:
2764
2764
  only counts. "Whether it merged" is checked: when an agent ran
2765
- `gh pr merge`, `usage` asks `gh pr view <ref> --json state` to confirm it.
2765
+ `gh pr merge`, `usage` asks `gh pr view <ref> --json number,state` to confirm it.
2766
2766
  That query, the pull request's number or URL, is the only thing it asks
2767
- outside, and the output says when it asked.
2767
+ outside, and the output says when it asked (or that it couldn't, because
2768
+ gh is not installed). A merge gh confirmed is kept in the record
2769
+ (`prMergeConfirmed`, the pull request's ref or, for a bare `gh pr merge`,
2770
+ the number gh gave) and not asked about again. A mark gh no longer
2771
+ confirms is dropped, with the "PR merged" rotation it made due.
2768
2772
 
2769
2773
  `<cli> usage report [--days 7] [--json]` writes a weekly summary to
2770
2774
  `<state dir>/usage/<date>.json` and `.md`, compared with the last one:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -80,6 +80,11 @@ export interface AgentOutcome {
80
80
  prOpened?: string;
81
81
  /** That pull request merged in the run. */
82
82
  prMerged?: boolean;
83
+ /**
84
+ * The pull request gh said is MERGED (0.4.10): a merge confirmed once
85
+ * stays, and is not asked again.
86
+ */
87
+ prMergeConfirmed?: string;
83
88
  /** The last test run it made passed (true) or failed (false). */
84
89
  testsGreen?: boolean;
85
90
  /** From its start to its last activity, in milliseconds. */
@@ -235,6 +235,15 @@ export function markDue(
235
235
  return true;
236
236
  }
237
237
 
238
+ /** Clear a rotation due for `reason` (a merge mark gh no longer confirms). */
239
+ export function clearDue(filesDir: string, reason: RotateReason): boolean {
240
+ const s = readRotation(filesDir);
241
+ if (!s || s.due !== reason) return false;
242
+ const { due: _d, dueAt: _a, ...rest } = s;
243
+ writeRotation(filesDir, rest);
244
+ return true;
245
+ }
246
+
238
247
  /** Whether the state says a rotation is due, for this conversation. */
239
248
  export function dueFor(
240
249
  s: RotationState | null,
@@ -6,27 +6,39 @@
6
6
  import { execFile } from "child_process";
7
7
 
8
8
  /**
9
- * `gh pr view [<ref>] [-R <repo>] --json state -q .state`;
10
- * rejects on any error. Only the ref and repo go out, to GitHub, by gh.
9
+ * `gh pr view [<ref>] [-R <repo>] --json number,state`: the state and the
10
+ * number (a bare ref is the branch's PR, and the number names it). Rejects
11
+ * on any error, or an answer that isn't that JSON. Only the ref and repo
12
+ * go out, to GitHub, by gh.
11
13
  */
12
14
  export function ghPrState(env: NodeJS.ProcessEnv) {
13
15
  return (ref: string, o: { repo?: string; cwd: string }) =>
14
- new Promise<string | undefined>((res, rej) => {
16
+ new Promise<{ state: string; number?: number }>((res, rej) => {
15
17
  const args = [
16
18
  "pr",
17
19
  "view",
18
20
  ...(ref ? [ref] : []),
19
21
  ...(o.repo ? ["-R", o.repo] : []),
20
22
  "--json",
21
- "state",
22
- "-q",
23
- ".state",
23
+ "number,state",
24
24
  ];
25
25
  execFile(
26
26
  "gh",
27
27
  args,
28
28
  { cwd: o.cwd, env, timeout: 15_000, maxBuffer: 64 * 1024 },
29
- (e, stdout) => (e ? rej(e) : res(String(stdout).trim())),
29
+ (e, stdout) => {
30
+ if (e) return rej(e);
31
+ try {
32
+ const o = JSON.parse(String(stdout)) as Record<string, unknown>;
33
+ if (typeof o.state !== "string") throw new Error("no state");
34
+ res({
35
+ state: o.state,
36
+ ...(typeof o.number === "number" ? { number: o.number } : {}),
37
+ });
38
+ } catch (err) {
39
+ rej(err);
40
+ }
41
+ },
30
42
  );
31
43
  });
32
44
  }
@@ -102,6 +102,19 @@ export function outcomeOf(
102
102
  return out;
103
103
  }
104
104
 
105
+ /**
106
+ * What a pull request check answers: its state ("MERGED"), and its number
107
+ * when gh gave one (a bare `gh pr merge` is the branch's PR: the number is
108
+ * how it is kept).
109
+ */
110
+ export type PrStateAnswer = string | { state: string; number?: number };
111
+
112
+ /** Checks one pull request's state (gh pr view), in the agent's folder. */
113
+ export type PrStateCheck = (
114
+ ref: string,
115
+ o: { repo?: string; cwd: string },
116
+ ) => Promise<PrStateAnswer | undefined>;
117
+
105
118
  /**
106
119
  * Whether one of the transcripts' `gh pr merge` claims is a pull request
107
120
  * gh says is MERGED. A bare `gh pr merge` (the branch's PR) is checked by
@@ -111,12 +124,9 @@ async function mergeVerified(
111
124
  rec: AgentRecord,
112
125
  stats: readonly TranscriptStats[],
113
126
  outcome: AgentOutcome,
114
- prState?: (
115
- ref: string,
116
- o: { repo?: string; cwd: string },
117
- ) => Promise<string | undefined>,
118
- ): Promise<boolean> {
119
- if (!prState) return false;
127
+ prState?: PrStateCheck,
128
+ ): Promise<string | undefined> {
129
+ if (!prState) return undefined;
120
130
  const seen = new Set<string>();
121
131
  for (const c of stats.flatMap((s) => s.prMerges)) {
122
132
  const ref = c.ref || outcome.prOpened || "";
@@ -128,12 +138,17 @@ async function mergeVerified(
128
138
  ...(c.repo ? { repo: c.repo } : {}),
129
139
  cwd: rec.cwd,
130
140
  });
131
- if (st?.trim().toUpperCase() === "MERGED") return true;
141
+ const state = typeof st === "string" ? st : st?.state;
142
+ if (state?.trim().toUpperCase() !== "MERGED") continue;
143
+ // Kept by its ref, or by the number gh gave; never by "".
144
+ const n = typeof st === "object" ? st.number : undefined;
145
+ const key = ref || (Number.isInteger(n) && n! > 0 ? String(n) : "");
146
+ if (key) return key;
132
147
  } catch {
133
148
  // Not checked: not marked.
134
149
  }
135
150
  }
136
- return false;
151
+ return undefined;
137
152
  }
138
153
 
139
154
  /**
@@ -146,14 +161,11 @@ export async function agentUsage(
146
161
  env?: NodeJS.ProcessEnv;
147
162
  source?: (runtime: string) => UsageSource | undefined;
148
163
  /**
149
- * A pull request's state (`gh pr view <ref> --json state`), run in the
164
+ * A pull request's state (`gh pr view <ref> --json number,state`), run in the
150
165
  * agent's folder: "MERGED" marks the outcome merged. Without it, or on
151
166
  * any error, nothing is marked: a merge is never taken from text.
152
167
  */
153
- prState?: (
154
- ref: string,
155
- o: { repo?: string; cwd: string },
156
- ) => Promise<string | undefined>;
168
+ prState?: PrStateCheck;
157
169
  } = {},
158
170
  ): Promise<AgentUsageRow[]> {
159
171
  const pick = opts.source ?? sourceFor;
@@ -198,8 +210,15 @@ export async function agentUsage(
198
210
  }
199
211
  const usage = sumUsage(stats);
200
212
  const outcome = outcomeOf(rec, stats);
201
- if (await mergeVerified(rec, stats, outcome, opts.prState))
213
+ // Confirmed once, kept: GitHub is not asked again.
214
+ const confirmed =
215
+ rec.outcome?.prMerged && rec.outcome.prMergeConfirmed
216
+ ? rec.outcome.prMergeConfirmed
217
+ : await mergeVerified(rec, stats, outcome, opts.prState);
218
+ if (confirmed) {
202
219
  outcome.prMerged = true;
220
+ outcome.prMergeConfirmed = confirmed;
221
+ }
203
222
  rows.push({
204
223
  ...base,
205
224
  transcripts: files.length,
@@ -3,7 +3,7 @@
3
3
  * runtimes' own transcripts on this machine (usage sources, plugin kind
4
4
  * `usage`). None of it is sent anywhere: it reads local files and writes
5
5
  * local files. The one question it asks outside: when an agent ran
6
- * `gh pr merge`, `gh pr view <ref> --json state` confirms the merge
6
+ * `gh pr merge`, `gh pr view <ref> --json number,state` confirms the merge
7
7
  * (gh-pr-state.ts) before it counts.
8
8
  *
9
9
  * agents [-w <ws>] [--json] each agent's tokens and outcome (PR opened
@@ -22,8 +22,9 @@ import {
22
22
  readRecord,
23
23
  writeRecord,
24
24
  } from "../../shared/agents/records.js";
25
- import { markDue } from "../../shared/agents/rotation.js";
25
+ import { clearDue, markDue } from "../../shared/agents/rotation.js";
26
26
  import { ghPrState } from "../../shared/git/gh-pr-state.js";
27
+ import type { PrStateCheck } from "../../shared/usage/report.js";
27
28
  import { readAgentsConfig } from "../../shared/den-config.js";
28
29
  import { getStateDir } from "../../shared/paths.js";
29
30
  import { product } from "../../shared/product.js";
@@ -77,24 +78,31 @@ export interface UsageDeps {
77
78
  rotateAfter?: () => { afterCompactions: number } | undefined;
78
79
  /**
79
80
  * A pull request's state, to confirm a `gh pr merge` the agent ran
80
- * (default: `gh pr view <ref> --json state` in the agent's folder).
81
+ * (default: `gh pr view <ref> --json number,state` in the agent's folder).
81
82
  */
82
- prState?: (
83
- ref: string,
84
- o: { repo?: string; cwd: string },
85
- ) => Promise<string | undefined>;
83
+ prState?: PrStateCheck;
86
84
  }
87
85
 
88
- /** The check, counting how often it asked (the summary line says so). */
86
+ /**
87
+ * The check, counting how often gh ran (`asked`) and how often it could not
88
+ * run because gh is not installed (`missing`); the summary says both.
89
+ */
89
90
  function counted(
90
91
  f: NonNullable<UsageDeps["prState"]>,
91
- ): NonNullable<UsageDeps["prState"]> & { asked: number } {
92
+ ): NonNullable<UsageDeps["prState"]> & { asked: number; missing: number } {
92
93
  const g = Object.assign(
93
- (ref: string, o: { repo?: string; cwd: string }) => {
94
- g.asked++;
95
- return f(ref, o);
94
+ async (ref: string, o: { repo?: string; cwd: string }) => {
95
+ try {
96
+ const st = await f(ref, o);
97
+ g.asked++;
98
+ return st;
99
+ } catch (e) {
100
+ if ((e as NodeJS.ErrnoException)?.code === "ENOENT") g.missing++;
101
+ else g.asked++;
102
+ throw e;
103
+ }
96
104
  },
97
- { asked: 0 },
105
+ { asked: 0, missing: 0 },
98
106
  );
99
107
  return g;
100
108
  }
@@ -144,6 +152,9 @@ function save(stateDir: string, rows: readonly AgentUsageRow[]): void {
144
152
  });
145
153
  if (r.outcome?.prMerged)
146
154
  markDue(agentFilesDir(stateDir, rec.id), "pr-merged", rec.resumeToken);
155
+ // A mark gh no longer confirms is dropped, and its rotation with it.
156
+ else if (rec.outcome?.prMerged)
157
+ clearDue(agentFilesDir(stateDir, rec.id), "pr-merged");
147
158
  }
148
159
  }
149
160
 
@@ -211,7 +222,9 @@ async function agents(
211
222
  : "red",
212
223
  r.outcome?.prOpened
213
224
  ? `${r.outcome.prOpened}${r.outcome.prMerged ? " (merged)" : ""}`
214
- : "-",
225
+ : r.outcome?.prMergeConfirmed
226
+ ? `${r.outcome.prMergeConfirmed} (merged)`
227
+ : "-",
215
228
  r.compactions === undefined ? "-" : String(r.compactions),
216
229
  String(r.rotations),
217
230
  ]),
@@ -226,6 +239,10 @@ async function agents(
226
239
  d.out(
227
240
  `To confirm a merge, it asked GitHub for the state of ${d.prState.asked} pull request${d.prState.asked === 1 ? "" : "s"} (gh pr view).`,
228
241
  );
242
+ if (d.prState.missing)
243
+ d.out(
244
+ `Couldn't check ${d.prState.missing} merge${d.prState.missing === 1 ? "" : "s"}: gh is not installed.`,
245
+ );
229
246
  return 0;
230
247
  }
231
248
 
@@ -792,10 +792,11 @@ export function groupReposByApplication(repos, apps) {
792
792
 
793
793
  /**
794
794
  * Feed the shell's Workspace part (shell.js #ws-pill, a menu): "All active"
795
- * and each active workspace, the focused one marked. Choosing one sets the
796
- * view scope (POST /api/scope, a preference) for every page. It replaces
797
- * dev.17's workspace pill and its focus menu (one control, not two): the
798
- * focus moves only through the menu's separate "Set <ws> as focus".
795
+ * and each active workspace, the focused one marked. Choosing a workspace
796
+ * makes it the CLI default (POST /api/workspaces `focus`, workspaces.yml)
797
+ * and sets the view (POST /api/scope, a preference) for every page: one
798
+ * "current workspace" (Ward's decision, 0.4.11). "All active" sets only the
799
+ * view; the CLI default stays.
799
800
  *
800
801
  * The arguments are dev.17's (an env select, an onSwitch callback) and are
801
802
  * no longer used: the view applies to every env.
@@ -822,7 +823,6 @@ async function _renderScopeMenu() {
822
823
  sh.setWorkspaces({
823
824
  ...buildScopeMenuModel(pageScope(scope, data), data, t),
824
825
  onSelect: _switchScope,
825
- onFocus: _setFocus,
826
826
  });
827
827
  } catch {
828
828
  sh.setWorkspaces(null);
@@ -852,25 +852,34 @@ function pageScope(scope, data) {
852
852
  }
853
853
 
854
854
  /**
855
- * On the Graph page a choice replaces ?ws= (All active drops it, a
856
- * workspace sets it), so the reload shows the choice rather than the old
857
- * ?ws=, which would win over it. The history entry is replaced, not
858
- * pushed: Back does not return to a view the picker has left.
855
+ * A choice replaces ?ws= (All active drops it, a workspace sets it) on the
856
+ * Graph and Agents pages, and on any page whose URL names a workspace
857
+ * (Workspaces' detail pane), so the reload shows the choice rather than
858
+ * the old ?ws=. The history entry is replaced, not pushed: Back does not
859
+ * return to a view the picker has left.
859
860
  */
860
861
  function followScopeInUrl(value) {
861
- if (!WS_URL_PAGES.has(location.pathname)) return;
862
862
  const u = new URL(location.href);
863
+ if (!WS_URL_PAGES.has(location.pathname) && !u.searchParams.has("ws")) return;
863
864
  if (value === "all") u.searchParams.delete("ws");
864
865
  else u.searchParams.set("ws", value);
865
866
  history.replaceState(history.state, "", u.pathname + u.search + u.hash);
866
867
  }
867
868
 
868
869
  /**
869
- * A view filter only: POST /api/scope stores the preference (user.yml
870
- * `view`), then the page reloads to show the new view whole. No service,
871
- * container, daemon or route changes, and the focus stays where it is.
870
+ * A workspace: first the CLI default (workspaces.yml), then the view. When
871
+ * the CLI default can't be set, nothing changes and the error shows. "All
872
+ * active": the view only. POST /api/scope stores the view (user.yml
873
+ * `view`), then the page reloads to show it whole. The workspace already
874
+ * in view (`inView`): the CLI default only, the view untouched. No
875
+ * service, container, daemon or route changes.
872
876
  */
873
- async function _switchScope(value) {
877
+ async function _switchScope(value, focus, inView) {
878
+ if (value !== "all" && value !== focus && !(await _setFocus(value))) return;
879
+ if (inView) {
880
+ location.reload();
881
+ return;
882
+ }
874
883
  try {
875
884
  await window.denShell.api("POST", "/api/scope", { scope: value });
876
885
  } catch (err) {
@@ -885,9 +894,9 @@ async function _switchScope(value) {
885
894
  }
886
895
 
887
896
  /**
888
- * "Set <ws> as focus": Local Dev's focus action (POST /api/workspaces
889
- * `focus`, which writes workspaces.yml and retargets the CLI). Separate
890
- * from the view: it is the only thing in the menu that moves the focus.
897
+ * Make `name` the CLI default: Local Dev's focus action (POST
898
+ * /api/workspaces `focus`, which writes workspaces.yml and retargets the
899
+ * CLI). False, with the error shown, when it couldn't.
891
900
  */
892
901
  async function _setFocus(name) {
893
902
  try {
@@ -900,9 +909,9 @@ async function _setFocus(name) {
900
909
  err.message = t("ws.focusFailed", { msg: err.message });
901
910
  window.denShell.errorToast(err);
902
911
  } else window.denShell?.toast?.(t("ws.focusFailed", { msg: err.message }));
903
- return;
912
+ return false;
904
913
  }
905
- location.reload();
914
+ return true;
906
915
  }
907
916
 
908
917
  // ── Health: the status chip and drawer (topbar.js, R6 and R15) ─────────────
@@ -29,7 +29,9 @@
29
29
  "ws.all": "Todos los activos",
30
30
  "ws.focus": "predeterminado de la CLI",
31
31
  "ws.focusTip":
32
- "Los comandos de la CLI se ejecutan aquí si no indicas un espacio de trabajo. Elegir un espacio en la barra superior solo cambia lo que muestran las páginas.",
32
+ "Los comandos de la CLI se ejecutan aquí si no indicas un espacio de trabajo. Elegir un espacio en la barra superior también lo convierte en el predeterminado de la CLI.",
33
+ "ws.pickTip":
34
+ "Elegir un espacio de trabajo aquí también lo convierte en el predeterminado de la CLI, así que los comandos de __DEN_CLI__ se ejecutan allí.",
33
35
  "ws.none": "No hay espacios de trabajo activos",
34
36
  "ws.manage": "Gestionar espacios de trabajo…",
35
37
  "ws.allN": "Todos los activos ({n})",
@@ -39,10 +41,7 @@
39
41
  "ws.viewTitle":
40
42
  "Vista: lo que muestra cada página ({ws}). Es solo un filtro: no inicia, detiene ni enruta nada.",
41
43
  "ws.viewTitleFocus":
42
- "Vista: lo que muestra cada página ({ws}). Es solo un filtro: no inicia, detiene ni enruta nada, y el predeterminado de la CLI ({focus}) sigue siendo el destino de los comandos.",
43
- "ws.setFocus": "Hacer de {ws} el predeterminado de la CLI",
44
- "ws.setFocusTip":
45
- "Lo convierte en el destino por defecto de los comandos (escribe workspaces.yml)",
44
+ "Vista: lo que muestra cada página ({ws}). No inicia, detiene ni enruta nada. Predeterminado de la CLI: {focus}.",
46
45
  "ws.viewFailed": "No se pudo cambiar la vista: {msg}",
47
46
  "ws.focusFailed": "No se pudo poner el foco: {msg}",
48
47
  "org.n": "{n} espacios de trabajo",
@@ -29,7 +29,9 @@
29
29
  "ws.all": "すべてのアクティブ",
30
30
  "ws.focus": "CLI の既定",
31
31
  "ws.focusTip":
32
- "ワークスペースを指定しないとき、CLI のコマンドはここで実行されます。上部バーでワークスペースを選んでも、ページの表示が変わるだけです。",
32
+ "ワークスペースを指定しないと CLI のコマンドはここで実行されます。上部バーでワークスペースを選ぶと、それが CLI の既定にもなります。",
33
+ "ws.pickTip":
34
+ "ここでワークスペースを選ぶと CLI の既定にもなり、__DEN_CLI__ のコマンドはそこで実行されます。",
33
35
  "ws.none": "アクティブなワークスペースはありません",
34
36
  "ws.manage": "ワークスペースを管理…",
35
37
  "ws.allN": "すべてのアクティブ ({n})",
@@ -39,10 +41,7 @@
39
41
  "ws.viewTitle":
40
42
  "表示: すべてのページに表示する範囲 ({ws})。表示の絞り込みだけで、何も起動・停止・ルーティングしません。",
41
43
  "ws.viewTitleFocus":
42
- "表示: すべてのページに表示する範囲 ({ws})。表示の絞り込みだけで、何も起動・停止・ルーティングしません。コマンドの対象は CLI の既定 ({focus}) のままです。",
43
- "ws.setFocus": "{ws} を CLI の既定にする",
44
- "ws.setFocusTip":
45
- "コマンドの既定の対象にします (workspaces.yml に書き込みます)",
44
+ "表示: 各ページに出るもの ({ws})。起動・停止・ルーティングは行いません。CLI の既定: {focus}。",
46
45
  "ws.viewFailed": "表示を変更できませんでした: {msg}",
47
46
  "ws.focusFailed": "フォーカスを設定できませんでした: {msg}",
48
47
  "org.n": "{n} 個のワークスペース",
@@ -1613,7 +1613,7 @@
1613
1613
  ? `<button class="danger" data-action="delete">Delete</button>`
1614
1614
  : "";
1615
1615
  return `<div class="ws-item ${n === activeWs ? "active" : ""}" data-name="${escHtml(n)}" data-action="select">
1616
- <span>${escHtml(n)}${isWsActive(n) ? ' <span style="font-size:10px;color:var(--muted)">(active)</span>' : ""}${n === focusName() ? ` <span class="ws-cli-default" title="${escHtml(wsT("ws.focusTip", "CLI commands run here when you don't name a workspace. Picking a workspace in the top bar only changes what pages show."))}">${escHtml(wsT("ws.focus", "CLI default"))}</span>` : ""}</span>
1616
+ <span>${escHtml(n)}${isWsActive(n) ? ' <span style="font-size:10px;color:var(--muted)">(active)</span>' : ""}${n === focusName() ? ` <span class="ws-cli-default" title="${escHtml(wsT("ws.focusTip", "CLI commands run here when you don't name a workspace. Picking a workspace in the top bar also makes it the CLI default."))}">${escHtml(wsT("ws.focus", "CLI default"))}</span>` : ""}</span>
1617
1617
  <span class="ws-item-right">
1618
1618
  <button class="ws-term-btn" title="Open in terminal" data-open-term="${escHtml(dirPath)}">&#62;_</button>
1619
1619
  <button class="ws-open-btn" title="Open in editor" data-open-path="${escHtml(wsData.codeWorkspacePaths?.[n] || dirPath)}">↗</button>
@@ -62,7 +62,7 @@
62
62
  * setOrgs([{ id, title, workspaces? }], currentId, onSelect?)
63
63
  * two or more: the Org ▾ menu; a pick
64
64
  * calls onSelect(id), fires `den:org`
65
- * setWorkspaces({ label, title, items, focusable, onSelect, onFocus })
65
+ * setWorkspaces({ label, title, items, focus, onSelect })
66
66
  * the Workspace ▾ menu (header.js)
67
67
  * syncEnv() repaint Env ▾ from #env-sel
68
68
  * registerCommand({ id, label, keywords?, kind?, run })
@@ -134,7 +134,9 @@
134
134
  "ws.all": "All active",
135
135
  "ws.focus": "CLI default",
136
136
  "ws.focusTip":
137
- "CLI commands run here when you don't name a workspace. Picking a workspace in the top bar only changes what pages show.",
137
+ "CLI commands run here when you don't name a workspace. Picking a workspace in the top bar also makes it the CLI default.",
138
+ "ws.pickTip":
139
+ "Picking a workspace here also makes it the CLI default, so __DEN_CLI__ commands run there.",
138
140
  "ws.none": "No active workspaces",
139
141
  "ws.manage": "Manage workspaces…",
140
142
  "ws.allN": "All active ({n})",
@@ -144,10 +146,7 @@
144
146
  "ws.viewTitle":
145
147
  "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing.",
146
148
  "ws.viewTitleFocus":
147
- "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing, and the CLI default ({focus}) stays the target for commands.",
148
- "ws.setFocus": "Make {ws} the CLI default",
149
- "ws.setFocusTip":
150
- "CLI commands run in it when you don't name a workspace (writes workspaces.yml)",
149
+ "View: what every page shows ({ws}). It starts, stops and routes nothing. CLI default: {focus}.",
151
150
  "ws.viewFailed": "Could not change the view: {msg}",
152
151
  "ws.focusFailed": "Could not change the CLI default: {msg}",
153
152
  // A count's singular is its .one (0.3.0-dev.40: en had none, so the org
@@ -1722,8 +1721,9 @@
1722
1721
  /**
1723
1722
  * The Workspace part: { label, title, items: [{ value, label, checked,
1724
1723
  * focused, title? }], onSelect(value) }. "All active" and the active
1725
- * workspaces, the focused one marked; then, apart, "Set <ws> as focus"
1726
- * (model.focusable, model.onFocus(name)) and a link to manage them.
1724
+ * workspaces, the focused one (model.focus, the CLI default) marked; then
1725
+ * a link to manage them. onSelect(value, focus): choosing a workspace
1726
+ * also makes it the CLI default (header.js).
1727
1727
  */
1728
1728
  function setWorkspaces(model) {
1729
1729
  var btn = document.getElementById("ws-pill");
@@ -1748,22 +1748,16 @@
1748
1748
  tag: it.focused ? t("ws.focus") : null,
1749
1749
  title: it.title || null,
1750
1750
  onSelect: function () {
1751
- if (!it.checked && typeof model.onSelect === "function")
1752
- model.onSelect(it.value);
1751
+ if (typeof model.onSelect !== "function") return;
1752
+ // The workspace in view still becomes the CLI default when it
1753
+ // isn't one; picking what is both already does nothing.
1754
+ if (it.checked && (it.value === "all" || it.value === model.focus))
1755
+ return;
1756
+ model.onSelect(it.value, model.focus || null, !!it.checked);
1753
1757
  },
1754
1758
  });
1755
1759
  });
1756
1760
  items.push({ hr: true });
1757
- // Focus is a separate action (it writes workspaces.yml and retargets
1758
- // the CLI); picking a view above never moves it.
1759
- if (model.focusable && typeof model.onFocus === "function")
1760
- items.push({
1761
- label: t("ws.setFocus", { ws: model.focusable }),
1762
- title: t("ws.setFocusTip"),
1763
- onSelect: function () {
1764
- model.onFocus(model.focusable);
1765
- },
1766
- });
1767
1761
  items.push({ label: t("ws.manage"), href: navHref("local-dev") });
1768
1762
  scopeMenus.ws = { label: t("k.ws"), items: items };
1769
1763
  }
@@ -25,7 +25,9 @@ export const EN_FALLBACK = {
25
25
  "ws.viewTitle":
26
26
  "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing.",
27
27
  "ws.viewTitleFocus":
28
- "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing, and the CLI default ({focus}) stays the target for commands.",
28
+ "View: what every page shows ({ws}). It starts, stops and routes nothing. CLI default: {focus}.",
29
+ "ws.pickTip":
30
+ "Picking a workspace here also makes it the CLI default, so __DEN_CLI__ commands run there.",
29
31
  };
30
32
  function enT(key, vars) {
31
33
  return String(EN_FALLBACK[key] ?? key).replace(/\{(\w+)\}/g, (m, k) =>
@@ -50,10 +52,10 @@ function focusName(data) {
50
52
  /**
51
53
  * The top bar's Workspace ▾ menu (R4, 0.3.0-dev.18), from the `/api/scope`
52
54
  * and `/api/workspaces` payloads. It is the view scope: what every page,
53
- * the health row and `status` show. Choosing an entry never moves the
54
- * focus (the default target for commands); the focused workspace is
55
- * marked, and `focusable` names the workspace in view when it is not the
56
- * focus, for the menu's separate "Set <ws> as focus" item.
55
+ * the health row and `status` show. Choosing a workspace also makes it the
56
+ * focus (the CLI default, Ward's decision in 0.4.11); "All active" leaves
57
+ * the focus where it is. The focused workspace is marked, and `focus`
58
+ * names it, so the caller can skip a choice that is already the focus.
57
59
  *
58
60
  * "All active" is offered with two or more active workspaces; with one,
59
61
  * it and that workspace are the same view.
@@ -85,12 +87,13 @@ export function buildScopeMenuModel(scope, data, t = enT) {
85
87
  label: n,
86
88
  checked: n === one,
87
89
  focused: n === focus,
90
+ title: t("ws.pickTip"),
88
91
  })),
89
92
  ];
90
93
  return {
91
94
  label: active.length === 0 ? t("ws.noneLabel") : (one ?? t("ws.all")),
92
95
  items,
93
- focusable: one && one !== focus ? one : null,
96
+ focus: focus ?? null,
94
97
  title: t(focus ? "ws.viewTitleFocus" : "ws.viewTitle", {
95
98
  ws: inView.join(", ") || t("ws.noneLabel"),
96
99
  focus,