@zq-silk/yui 2.14.0 → 2.16.0

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.
Files changed (70) hide show
  1. package/dist/config/configCatalog.js +1 -1
  2. package/dist/controlPlaneCli.js +1 -1
  3. package/dist/job/projectSync.js +3 -3
  4. package/dist/storage/sqliteSchema.js +12 -0
  5. package/dist/storage/storageVersions.js +1 -1
  6. package/dist/storage/upgrade/projectSyncClockMigration.js +30 -0
  7. package/dist/web/assets/assetManifest.js +23 -1
  8. package/dist/web/assets/client/app/catalog.js +40 -14
  9. package/dist/web/assets/client/app/login.js +156 -0
  10. package/dist/web/assets/client/app/main.js +33 -9
  11. package/dist/web/assets/client/app/manage.js +18 -0
  12. package/dist/web/assets/client/app/selection.js +4 -1
  13. package/dist/web/assets/client/app/taskView.js +22 -5
  14. package/dist/web/assets/client/i18n/en.js +28 -4
  15. package/dist/web/assets/client/i18n/zh-CN.js +28 -4
  16. package/dist/web/assets/client/index.js +8 -0
  17. package/dist/web/assets/client/layout/manageShell.js +30 -8
  18. package/dist/web/assets/client/layout/shortcuts.js +7 -3
  19. package/dist/web/assets/client/layout/sidebarNav.js +147 -0
  20. package/dist/web/assets/client/layout/sizing.js +5 -2
  21. package/dist/web/assets/client/layout/workspace.js +120 -21
  22. package/dist/web/assets/client/lib/api.js +5 -0
  23. package/dist/web/assets/client/lib/motion.js +59 -0
  24. package/dist/web/assets/client/lib/prefs.js +29 -2
  25. package/dist/web/assets/client/lib/session.js +21 -0
  26. package/dist/web/assets/client/lib/theme.js +11 -4
  27. package/dist/web/assets/client/ui/primitives.js +27 -0
  28. package/dist/web/assets/client/views/dock/terminal.js +4 -1
  29. package/dist/web/assets/client/views/overview.js +8 -5
  30. package/dist/web/assets/client/views/task/page.js +10 -10
  31. package/dist/web/assets/shared/brand.js +22 -0
  32. package/dist/web/assets/shared/geometry.js +7 -1
  33. package/dist/web/assets/shared/icons.js +5 -0
  34. package/dist/web/assets/shared/motion.js +74 -0
  35. package/dist/web/assets/shell/appRail.js +14 -0
  36. package/dist/web/assets/shell/dialogs.js +1 -0
  37. package/dist/web/assets/shell/dock.js +19 -6
  38. package/dist/web/assets/shell/index.js +6 -2
  39. package/dist/web/assets/shell/login.js +57 -0
  40. package/dist/web/assets/shell/manage.js +32 -20
  41. package/dist/web/assets/shell/settingsContent.js +1 -1
  42. package/dist/web/assets/shell/sidebar.js +16 -23
  43. package/dist/web/assets/styles/base.js +1 -1
  44. package/dist/web/assets/styles/index.js +4 -0
  45. package/dist/web/assets/styles/layout/responsive.js +63 -19
  46. package/dist/web/assets/styles/layout/workspace.js +53 -16
  47. package/dist/web/assets/styles/motion.js +51 -0
  48. package/dist/web/assets/styles/tokens.js +5 -3
  49. package/dist/web/assets/styles/ui/controls.js +1 -1
  50. package/dist/web/assets/styles/ui/forms.js +1 -1
  51. package/dist/web/assets/styles/ui/surfaces.js +1 -2
  52. package/dist/web/assets/styles/views/conversation.js +1 -1
  53. package/dist/web/assets/styles/views/dock.js +22 -2
  54. package/dist/web/assets/styles/views/login.js +45 -0
  55. package/dist/web/assets/styles/views/manage.js +25 -42
  56. package/dist/web/assets/styles/views/page.js +2 -3
  57. package/dist/web/assets/styles/views/records.js +1 -1
  58. package/dist/web/assets/styles/views/settings.js +1 -1
  59. package/dist/web/assets/styles/views/sidebar.js +25 -20
  60. package/dist/web/assets/styles/views/task.js +9 -15
  61. package/dist/web/assets/styles/views/terminal.js +1 -1
  62. package/dist/web/webAccess.js +129 -9
  63. package/dist/web/webServer.js +170 -14
  64. package/docs/release-workflow.md +67 -0
  65. package/docs/release-workflow.zh-CN.md +49 -0
  66. package/docs/storage-baseline.md +7 -1
  67. package/docs/storage-baseline.zh-CN.md +6 -1
  68. package/docs/testing/verification-levels.md +77 -11
  69. package/docs/testing/verification-levels.zh-CN.md +63 -9
  70. package/package.json +1 -1
@@ -8,7 +8,7 @@ export const CONFIG_DOMAINS = ["system", "runtime", "workflow", "resources", "to
8
8
  export const CONFIG_DEFINITIONS = Object.freeze([
9
9
  { key: "default-agent", domain: "system", property: "defaultAgent", label: "Default Agent", summary: "Configured Agent used when a Task or Role does not select one explicitly.", takesEffect: "Future Role and Task defaults; existing bindings are unchanged." },
10
10
  { key: "default-workspace", domain: "system", property: "defaultWorkspace", label: "Default workspace", summary: "Absolute workspace root outside YUI_HOME used when no workspace is selected.", takesEffect: "Future workspace selection; existing Roles and worktrees are unchanged." },
11
- { key: "time-zone", domain: "system", property: "timeZone", label: "Time zone", summary: "IANA timezone for human-facing timestamps (default: Asia/Shanghai).", takesEffect: "The next human-readable timestamp rendering; durable JSON remains UTC." },
11
+ { key: "time-zone", domain: "system", property: "timeZone", label: "Time zone", summary: "IANA timezone for human-facing timestamps and new default Project sync plans (default: Asia/Shanghai).", takesEffect: "The next human-readable timestamp rendering and newly installed Project sync plans; existing Schedule rules stay unchanged and durable timestamps remain UTC." },
12
12
  { key: "reconciliation-interval-seconds", domain: "runtime", property: "reconciliationIntervalSeconds", label: "Reconciliation interval", summary: "Recovery reconciliation interval, 5-300 seconds (default: 120).", takesEffect: "The running Controller is refreshed after the value is saved." },
13
13
  { key: "controller-task-concurrency", domain: "runtime", property: "controllerTaskConcurrency", label: "Controller Task concurrency", summary: "Maximum Tasks reconciled concurrently, 1-32 (default: 4).", takesEffect: "After the Controller restarts." },
14
14
  { key: "runtime-health", domain: "runtime", property: "runtimeHealth", label: "Runtime health thresholds", summary: "Quiet, checkpoint-overdue, and read-only diagnostic thresholds in seconds; values must be strictly increasing (defaults: 300/900/1800).", takesEffect: "CLI and Web projections immediately; scheduler diagnostics after the Controller restarts." },
@@ -689,7 +689,7 @@ export async function main() {
689
689
  const describe = (status) => status
690
690
  ? `Web listener: ${status.state}\n`
691
691
  + (status.alreadyRunning ? "Existing listener reused; credentials were not reloaded.\n" : "")
692
- + (status.authentication === "basic" ? "Browser authentication: username yui; use the password from your private credential file.\n" : "")
692
+ + (status.authentication === "password" ? "Browser sign-in: open the URL and enter the password from your private credential file on the Yui sign-in page.\n" : "")
693
693
  + (status.urls ?? []).map(url => `Yui web control room: ${url}\n`).join("")
694
694
  + (status.warnings ?? []).map(warning => `Warning: ${warning}\n`).join("")
695
695
  : "Web is not running. Start it explicitly; Controller recovery does not restore listeners.\n";
@@ -7,9 +7,9 @@ import { requireRefreshProject, projectRefreshTargetDigest } from "../repository
7
7
  import { createJobDefinition, reviseJobDefinition, validateJobDefinition } from "./jobDefinition.js";
8
8
  import { nextScheduleDue, validateScheduleRule } from "../schedule/schedule.js";
9
9
  import { scheduleServiceBinding } from "./scheduleService.js";
10
+ import { resolveTimeZone } from "../config/timeZone.js";
10
11
  const scope = { kind: "global" };
11
12
  const unavailable = "Project sync unavailable: ";
12
- export const PROJECT_SYNC_INTERVAL_MS = 6 * 60 * 60_000;
13
13
  /** Called under the maintenance lock immediately before Git, not a timer probe. */
14
14
  export function requireProjectSyncCheckout(store, job) {
15
15
  requireProjectSyncTarget(store, job);
@@ -86,8 +86,8 @@ export function reconcileProjectSync(store, now, onError = () => { }) {
86
86
  reason = unavailable + (error instanceof Error ? error.message : String(error));
87
87
  }
88
88
  if (!plan) {
89
- const rule = { kind: "interval", anchor: new Date(now.getTime() + PROJECT_SYNC_INTERVAL_MS).toISOString(),
90
- everyMs: PROJECT_SYNC_INTERVAL_MS };
89
+ const rule = { kind: "cron", expression: "0 */6 * * *",
90
+ timezone: resolveTimeZone(tx.getConfig().timeZone) };
91
91
  const grantId = randomUUID();
92
92
  plan = { schemaVersion: 1, id, scope, jobId: id, target: { kind: "existing-job" }, rule,
93
93
  builtin: { projectId, enabled: true }, revision: 1, generation: 1,
@@ -10,6 +10,7 @@ import { GLOBAL_SESSION_TABLE_SQL, SESSION_HISTORY_UPGRADE_SQL } from "./session
10
10
  import { migrateJobDefinitions } from "./upgrade/jobDefinitionMigration.js";
11
11
  import { migrateJobBusinessResults } from "./upgrade/jobBusinessResultMigration.js";
12
12
  import { migrateScheduleDispatch } from "./upgrade/scheduleDispatchMigration.js";
13
+ import { migrateProjectSyncClock } from "./upgrade/projectSyncClockMigration.js";
13
14
  import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION, STORAGE_FORMAT, isMinorStorageUpgrade, isStorageVersion, storageVersionParts } from "./storageVersions.js";
14
15
  export { BASELINE_SCHEMA_SQL };
15
16
  export const SQLITE_SCHEMA_TABLES = [...BASELINE_SCHEMA_TABLES, "job_definitions", "global_job_runs", "schedules", "agent_job_runs"];
@@ -233,6 +234,15 @@ const MINOR_UPGRADES = Object.freeze([{
233
234
  // Existing records and immutable artifacts remain byte-for-byte unchanged.
234
235
  // Declares Agent definitions/Runs and optional Job ownership of a non-Task Role.
235
236
  sql: AGENT_JOB_SCHEMA_SQL
237
+ }, {
238
+ fromVersion: "1.20",
239
+ toVersion: "1.21",
240
+ name: "project-sync-clock",
241
+ introducedIn: "next",
242
+ sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
243
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
244
+ sql: "",
245
+ dataMigration: "project-sync-clock"
236
246
  }]);
237
247
  export function storageMinorUpgradePlan(from) {
238
248
  if (from === CURRENT_STORAGE_VERSION)
@@ -363,6 +373,8 @@ export function applySqliteMinorUpgrades(db, home, createdRoots = []) {
363
373
  migrateJobBusinessResults(db);
364
374
  if (step.dataMigration === "schedule-dispatch")
365
375
  migrateScheduleDispatch(db);
376
+ if (step.dataMigration === "project-sync-clock")
377
+ migrateProjectSyncClock(db);
366
378
  const { major, minor } = storageVersionParts(step.toVersion);
367
379
  db.prepare("UPDATE storage_schema SET major=?,minor=?,checksum=? WHERE id=1")
368
380
  .run(major, minor, step.targetChecksum);
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.20";
1
+ export const CURRENT_STORAGE_VERSION = "1.21";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -0,0 +1,30 @@
1
+ import { resolveTimeZone } from "../../config/timeZone.js";
2
+ import { nextScheduleDue, validateSchedule } from "../../schedule/schedule.js";
3
+ /** 1.20 had no rule-edit provenance. Only revision 1 proves no user mutation.
4
+ * Higher revisions may be dispatch, recovery OR an equal-valued user edit;
5
+ * preserve them instead of guessing from interval values or generation.
6
+ */
7
+ export function migrateProjectSyncClock(db) {
8
+ const now = new Date();
9
+ const rows = db.prepare("SELECT rowid,scope_key,schedule_id,payload FROM schedules").all();
10
+ for (const row of rows) {
11
+ const old = JSON.parse(row.payload);
12
+ if (!old.builtin || old.revision !== 1)
13
+ continue;
14
+ validateSchedule(old);
15
+ // Confirm the declared install shape, in addition to the mutation evidence.
16
+ if (old.generation !== 1 || old.rule.kind !== "interval" || old.rule.everyMs !== 21_600_000
17
+ || Date.parse(old.rule.anchor) !== Date.parse(old.createdAt) + 21_600_000)
18
+ continue;
19
+ const config = db.prepare("SELECT payload FROM config WHERE id=1").get();
20
+ const rule = { kind: "cron", expression: "0 */6 * * *",
21
+ timezone: resolveTimeZone(config ? JSON.parse(config.payload).timeZone : undefined) };
22
+ const after = new Date(Math.max(now.getTime(), Date.parse(old.processedThrough ?? "") || 0));
23
+ const next = validateSchedule({ ...old, rule, revision: old.revision + 1, generation: old.generation + 1,
24
+ processedThrough: after.toISOString(), nextDue: nextScheduleDue(rule, after), updatedAt: now.toISOString() });
25
+ db.prepare("INSERT INTO storage_migration_archive(migration_version,family,record_key,payload) VALUES(?,?,?,?)")
26
+ .run(21, "schedules", `${row.scope_key}/${row.schedule_id}`, row.payload);
27
+ db.prepare("UPDATE schedules SET payload=?,next_due=? WHERE rowid=?")
28
+ .run(JSON.stringify(next), next.nextDue, row.rowid);
29
+ }
30
+ }
@@ -8,6 +8,7 @@ import { APP_SCRIPT, CLIENT_MODULES } from "./client/index.js";
8
8
  import { THEME_BOOTSTRAP_SCRIPT } from "./client/lib/theme.js";
9
9
  import { FONT_WOFF2_BASE64 } from "./fontData.js";
10
10
  import { DASHBOARD_HTML } from "./shell/index.js";
11
+ import { LOGIN_HTML } from "./shell/login.js";
11
12
  import { STYLESHEETS } from "./styles/index.js";
12
13
  const require = createRequire(import.meta.url);
13
14
  const CSS = "text/css; charset=utf-8";
@@ -22,7 +23,28 @@ export const WEB_ASSETS = Object.freeze({
22
23
  "/assets/vendor/addon-fit.mjs": vendorAsset("@xterm/addon-fit/lib/addon-fit.mjs", JS),
23
24
  "/assets/vendor/xterm.css": vendorAsset("@xterm/xterm/css/xterm.css", CSS)
24
25
  });
25
- export { DASHBOARD_HTML };
26
+ export { DASHBOARD_HTML, LOGIN_HTML };
27
+ // The sign-in document's static closure (its stylesheets, fonts, theme script
28
+ // and module import graph). Only these assets are served without a session;
29
+ // every other asset, like every document and API, requires sign-in.
30
+ const LOGIN_ASSETS = (() => {
31
+ const found = new Set();
32
+ const visit = (body) => {
33
+ for (const [path] of body.matchAll(/\/assets\/[\w./-]+/g)) {
34
+ const asset = WEB_ASSETS[path];
35
+ if (!asset || found.has(path))
36
+ continue;
37
+ found.add(path);
38
+ if (asset.encoding !== "base64")
39
+ visit(asset.body);
40
+ }
41
+ };
42
+ visit(LOGIN_HTML);
43
+ return found;
44
+ })();
45
+ export function isLoginAsset(pathname) {
46
+ return LOGIN_ASSETS.has(pathname);
47
+ }
26
48
  // One content revision covers the complete import graph: changing a dependency
27
49
  // invalidates its importers too. Only these static assets may be cached.
28
50
  export const WEB_ASSET_VERSION = createHash("sha256")
@@ -51,8 +51,21 @@ async function refresh(deps, progress, options) {
51
51
  const query = catalogQuery(state);
52
52
  const dashboard = await deps.api.dashboard(query);
53
53
  if (request !== progress.request) return;
54
- applyDashboard(state, dashboard, query);
55
- showDashboard(deps, progress, previousInputs);
54
+ // An unchanged quiet poll leaves the list and overview alone: rebuilding
55
+ // them every few seconds would interrupt scrolling and animations. They
56
+ // still redraw once a minute so relative times stay true.
57
+ const signature = query + "|" + JSON.stringify(dashboard);
58
+ const now = Date.now();
59
+ if (quiet && signature === progress.signature && now - progress.drawnAt < 60000) {
60
+ state.generatedAt = new Date().toISOString();
61
+ el.sync.dataset.state = "ok";
62
+ el.lastSync.textContent = formatClock(state.generatedAt, deps.locale());
63
+ } else {
64
+ progress.signature = signature;
65
+ progress.drawnAt = now;
66
+ applyDashboard(state, dashboard, query);
67
+ showDashboard(deps, progress, previousInputs);
68
+ }
56
69
  // A selected Task may live on another page or outside current filters;
57
70
  // only its own read can establish that it no longer exists.
58
71
  if (state.selected) {
@@ -98,18 +111,10 @@ function renderSidebar(deps, progress) {
98
111
  reset(deps, progress);
99
112
  });
100
113
  el.catalogCount.textContent = fill(t("catalog.count"), { shown: state.tasks.length, total: state.catalogTotal });
101
- let source = el.filters.querySelector("select");
102
- if (!source) {
103
- source = h("select", { "aria-label": t("jobs.taskCategory") });
104
- source.addEventListener("change", function () {
105
- state.source = source.value; state.attentionFilter = null; state.query = ""; el.search.value = "";
106
- syncUrl(state, false); reset(deps, progress);
107
- });
108
- el.filters.append(source);
109
- }
110
- source.replaceChildren(...["ordinary", "job", "all"].map(value =>
111
- h("option", { value }, t("jobs.category." + value))));
112
- source.value = state.query.trim() || state.attentionFilter ? "all" : state.source || "ordinary";
114
+ renderSource(el.source, state, t, function (value) {
115
+ state.source = value; state.attentionFilter = null; state.query = ""; el.search.value = "";
116
+ syncUrl(state, false); reset(deps, progress);
117
+ });
113
118
  el.catalogReset.hidden = !state.catalogCursor;
114
119
  el.catalogNext.hidden = !state.nextCursor && !state.catalogCursor;
115
120
  el.catalogAttentionReset.hidden = !state.attentionFilter;
@@ -117,6 +122,27 @@ function renderSidebar(deps, progress) {
117
122
  el.lastSync.textContent = state.generatedAt ? formatClock(state.generatedAt, deps.locale()) : "—";
118
123
  }
119
124
 
125
+ // Task category: search and attention are cross-category, so they show "all";
126
+ // picking a category clears them, as the server filter then applies again.
127
+ function renderSource(container, state, t, onPick) {
128
+ const current = state.query.trim() || state.attentionFilter ? "all" : state.source || "ordinary";
129
+ // Bound once; the handler reads the live state, not this render's snapshot.
130
+ if (!container.childElementCount) {
131
+ ["ordinary", "job", "all"].forEach(function (value) {
132
+ container.append(h("button.seg-btn", { type: "button", role: "radio", dataset: { source: value },
133
+ onclick: function () {
134
+ if (value !== (state.source || "ordinary") || state.query.trim() || state.attentionFilter) onPick(value);
135
+ } }));
136
+ });
137
+ }
138
+ container.querySelectorAll("[data-source]").forEach(function (button) {
139
+ const value = button.dataset.source;
140
+ button.textContent = t("jobs.categoryShort." + value);
141
+ button.title = t("jobs.category." + value);
142
+ button.setAttribute("aria-checked", String(value === current));
143
+ });
144
+ }
145
+
120
146
  // Redraw the list in place, keeping its scroll position.
121
147
  function renderTaskList(deps) {
122
148
  const scroll = deps.el.tasks.scrollTop;
@@ -0,0 +1,156 @@
1
+ export const LOGIN_SCRIPT = String.raw `
2
+ // Sign-in page controller. Posts the password once per submission as
3
+ // same-origin JSON; the server sets an HttpOnly session cookie and returns a
4
+ // validated same-origin destination. The password is never written to the
5
+ // URL, storage or console, and the field is cleared after a rejection.
6
+ import { createI18n } from "/assets/js/lib/i18n.js";
7
+
8
+ const form = document.querySelector("#login-form");
9
+ const password = document.querySelector("#login-password");
10
+ const reveal = document.querySelector("#login-reveal");
11
+ const submit = document.querySelector("#login-submit");
12
+ const submitLabel = document.querySelector("#login-submit-label");
13
+ const errorBox = document.querySelector("#login-error");
14
+ const notice = document.querySelector("#login-notice");
15
+ const transport = document.querySelector("#login-transport");
16
+
17
+ const i18n = createI18n(document.querySelector("#locale-select"));
18
+ const t = i18n.t;
19
+ const params = new URLSearchParams(window.location.search);
20
+ const next = params.get("next") || "/";
21
+ const reason = params.get("reason");
22
+ let busy = false;
23
+ let lockedUntil = 0;
24
+ let lockTimer = null;
25
+ let currentError = null;
26
+
27
+ function isLoopbackHost(host) {
28
+ return host === "localhost" || host === "[::1]" || /^127\./.test(host);
29
+ }
30
+
31
+ function showError(key, values) {
32
+ currentError = { key: key, values: values || {} };
33
+ let text = t(key);
34
+ Object.keys(currentError.values).forEach(function (name) { text = text.split("{" + name + "}").join(String(currentError.values[name])); });
35
+ errorBox.textContent = text;
36
+ errorBox.hidden = false;
37
+ password.setAttribute("aria-invalid", key === "login.error.network" || key === "login.error.origin" ? "false" : "true");
38
+ }
39
+
40
+ function clearError() {
41
+ currentError = null;
42
+ errorBox.hidden = true;
43
+ errorBox.textContent = "";
44
+ password.setAttribute("aria-invalid", "false");
45
+ }
46
+
47
+ function setBusy(value) {
48
+ busy = value;
49
+ form.setAttribute("aria-busy", value ? "true" : "false");
50
+ password.readOnly = value;
51
+ updateSubmit();
52
+ }
53
+
54
+ function updateSubmit() {
55
+ const locked = Date.now() < lockedUntil;
56
+ submit.disabled = busy || locked;
57
+ submitLabel.textContent = t(busy ? "login.submitting" : "login.submit");
58
+ }
59
+
60
+ function renderStatic() {
61
+ document.title = t("login.title");
62
+ if (reason === "expired" || reason === "signed-out") {
63
+ notice.className = "login-notice " + (reason === "expired" ? "tone-warn" : "tone-ok");
64
+ notice.textContent = t(reason === "expired" ? "login.notice.expired" : "login.notice.signedOut");
65
+ notice.hidden = false;
66
+ }
67
+ const revealed = reveal.getAttribute("aria-pressed") === "true";
68
+ reveal.setAttribute("aria-label", t(revealed ? "login.hide" : "login.show"));
69
+ reveal.title = t(revealed ? "login.hide" : "login.show");
70
+ if (currentError) showError(currentError.key, currentError.values);
71
+ updateSubmit();
72
+ }
73
+
74
+ function lockFor(seconds) {
75
+ lockedUntil = Date.now() + seconds * 1000;
76
+ window.clearInterval(lockTimer);
77
+ const tick = function () {
78
+ const remaining = Math.ceil((lockedUntil - Date.now()) / 1000);
79
+ if (remaining <= 0) {
80
+ window.clearInterval(lockTimer);
81
+ lockTimer = null;
82
+ clearError();
83
+ updateSubmit();
84
+ return;
85
+ }
86
+ showError("login.error.throttled", { seconds: remaining });
87
+ updateSubmit();
88
+ };
89
+ tick();
90
+ lockTimer = window.setInterval(tick, 1000);
91
+ }
92
+
93
+ reveal.addEventListener("click", function () {
94
+ const revealed = reveal.getAttribute("aria-pressed") !== "true";
95
+ reveal.setAttribute("aria-pressed", revealed ? "true" : "false");
96
+ password.type = revealed ? "text" : "password";
97
+ renderStatic();
98
+ password.focus();
99
+ });
100
+
101
+ password.addEventListener("input", function () {
102
+ if (currentError && currentError.key !== "login.error.throttled") clearError();
103
+ });
104
+
105
+ form.addEventListener("submit", async function (event) {
106
+ event.preventDefault();
107
+ if (busy || Date.now() < lockedUntil) return;
108
+ if (!password.value) {
109
+ showError("login.error.empty");
110
+ password.focus();
111
+ return;
112
+ }
113
+ clearError();
114
+ setBusy(true);
115
+ let response;
116
+ let body = null;
117
+ try {
118
+ response = await fetch("/auth/login", {
119
+ method: "POST", credentials: "same-origin", cache: "no-store",
120
+ headers: { "content-type": "application/json", accept: "application/json" },
121
+ body: JSON.stringify({ password: password.value, next: next })
122
+ });
123
+ try { body = await response.json(); } catch {}
124
+ } catch {
125
+ setBusy(false);
126
+ showError("login.error.network");
127
+ password.focus();
128
+ return;
129
+ }
130
+ if (response.ok && body && typeof body.location === "string" && body.location.startsWith("/") && !body.location.startsWith("//")) {
131
+ // Keep the busy state while the browser navigates to the destination.
132
+ window.location.replace(body.location + window.location.hash);
133
+ return;
134
+ }
135
+ setBusy(false);
136
+ if (response.status === 429) {
137
+ lockFor(Math.max(1, Number(body && body.retryAfterSeconds) || Number(response.headers.get("retry-after")) || 30));
138
+ } else if (response.status === 401) {
139
+ password.value = "";
140
+ showError("login.error.invalid");
141
+ } else if (response.status === 403) {
142
+ showError("login.error.origin");
143
+ } else if (response.status === 404 && body && body.code === "password-not-configured") {
144
+ window.location.replace("/");
145
+ return;
146
+ } else {
147
+ showError("login.error.network");
148
+ }
149
+ password.focus();
150
+ });
151
+
152
+ i18n.subscribe(renderStatic);
153
+ transport.hidden = window.location.protocol !== "http:" || isLoopbackHost(window.location.hostname);
154
+ renderStatic();
155
+ password.focus();
156
+ `;
@@ -17,7 +17,7 @@ export const APP_SCRIPT = String.raw `
17
17
  // refresh catalog.refresh → sidebar, then the overview (nothing selected)
18
18
  // or selection.reload → taskView.render
19
19
  // select sidebar row / overview row / URL → selection.selectTask
20
- // leave back button or Escape → selection.clearSelection
20
+ // leave rail Workbench or Escape → selection.clearSelection
21
21
  // write Task page or discussion afterWrite → quiet catalog refresh
22
22
  // URL load and back/forward → catalog.applyFilters + selection.followUrl
23
23
  import { createI18n } from "/assets/js/lib/i18n.js";
@@ -31,7 +31,9 @@ import { bindGlobalInput } from "/assets/js/views/globalInput.js";
31
31
  import { bindCreateTask } from "/assets/js/views/createTask.js";
32
32
  import { bindBodySearch } from "/assets/js/views/bodySearch.js";
33
33
  import { readQuery } from "/assets/js/layout/router.js";
34
+ import { rememberPlace, linkPlaces } from "/assets/js/lib/prefs.js";
34
35
  import { createWorkspace } from "/assets/js/layout/workspace.js";
36
+ import { createSidebarNav } from "/assets/js/layout/sidebarNav.js";
35
37
  import { bindShortcuts } from "/assets/js/layout/shortcuts.js";
36
38
  import { createTaskView } from "/assets/js/app/taskView.js";
37
39
  import { createSelection } from "/assets/js/app/selection.js";
@@ -39,12 +41,14 @@ import { createCatalog } from "/assets/js/app/catalog.js";
39
41
 
40
42
  const $ = function (selector) { return document.querySelector(selector); };
41
43
  const el = {
42
- sidebar: $(".sidebar"), sidebarDivider: $("#sidebar-divider"), center: $("#center"), detail: $("#detail"),
43
- search: $("#search"), filters: $("#status-filters"), attention: $("#attention-bar"), tasks: $("#task-list"),
44
+ sidebar: $(".sidebar"), rail: $(".app-rail"), railWorkbench: $("#rail-workbench"),
45
+ sidebarScrim: $("#sidebar-scrim"), sidebarCollapse: $("#sidebar-collapse"), sidebarDivider: $("#sidebar-divider"), center: $("#center"), detail: $("#detail"),
46
+ search: $("#search"), source: $("#catalog-source"), filters: $("#status-filters"), attention: $("#attention-bar"), tasks: $("#task-list"),
44
47
  catalogNext: $("#catalog-next"), catalogReset: $("#catalog-reset"), catalogCount: $("#catalog-count"),
45
48
  catalogAttentionReset: $("#catalog-attention-reset"),
46
49
  refresh: $("#refresh"), sync: $("#sync-state"), lastSync: $("#last-sync"), toast: $("#toast"),
47
- operator: $("#operator-terminal"), settingsOpen: $("#settings-open"), settings: $("#settings-dialog"),
50
+ dockTarget: $("#dock-target"), dockTargetLabel: $("#dock-target-label"), dockTargetIcon: $("#dock-target-icon"),
51
+ dockTargetOptions: $("#dock-target-options"), globalInputOpen: $("#global-input-open"), settings: $("#settings-dialog"),
48
52
  locale: $("#locale-select"), themeOptions: $("#theme-options"),
49
53
  dock: $("#dock"), divider: $("#dock-divider"), dockSwap: $("#dock-swap"), dockClose: $("#dock-close"),
50
54
  dockTabDiscussion: $("#dock-tab-discussion"), dockTabSession: $("#dock-tab-session"),
@@ -52,7 +56,7 @@ const el = {
52
56
  dockTabPreview: $("#dock-tab-preview"), preview: $("#dock-preview"),
53
57
  discussion: $("#dock-discussion"), session: $("#dock-session"),
54
58
  terminalHost: $("#terminal-host"), terminalEmpty: $("#terminal-empty"), terminalState: $("#terminal-state"),
55
- sessionTargets: $("#session-targets"), terminalCli: $("#terminal-cli")
59
+ sessionTargets: $("#session-targets"), settingsOpen: $("#settings-open"), terminalCli: $("#terminal-cli")
56
60
  };
57
61
 
58
62
  // View state. Each group has exactly one writer.
@@ -86,13 +90,18 @@ let taskView = null;
86
90
  let selection = null;
87
91
  let catalog = null;
88
92
  const workspace = createWorkspace({
89
- el: el, state: state, terminal: terminal, conversation: conversation, preview: preview,
93
+ el: el, state: state, t: t, terminal: terminal, conversation: conversation, preview: preview,
90
94
  sessionOpened: function () { taskView.updateSessionTargets(); }
91
95
  });
96
+ const sidebarNav = createSidebarNav({
97
+ el: { sidebar: el.sidebar, rail: el.rail, scrim: el.sidebarScrim, collapse: el.sidebarCollapse },
98
+ relayout: function () { workspace.updateLayout(); }
99
+ });
92
100
  taskView = createTaskView({
93
101
  el: el, state: state, t: t, locale: locale, api: api, toast: toast, terminal: terminal, workspace: workspace,
94
102
  selectTask: function (taskId) { return selection.selectTask(taskId); },
95
103
  clearSelection: function () { selection.clearSelection(); },
104
+ revealSidebar: function () { sidebarNav.reveal(); },
96
105
  pickAttention: function (kind) { catalog.pickAttention(kind); },
97
106
  readPageSessions: function () { return catalog.readPageSessions(); },
98
107
  refreshCatalog: function () { return catalog.refresh({ quiet: true }); }
@@ -114,10 +123,24 @@ bindBodySearch({ t: t, api: api, selectTask: selection.selectTask,
114
123
  currentTask: function () { return state.selected; },
115
124
  captureDraftTarget: conversation.captureDraftTarget, insertReference: conversation.insertReference });
116
125
  bindShortcuts({
117
- el: el, state: state, globalInput: globalInput, workspace: workspace,
126
+ el: el, state: state, globalInput: globalInput, workspace: workspace, sidebarNav: sidebarNav,
118
127
  selection: selection, catalog: catalog, taskView: taskView
119
128
  });
120
- el.settingsOpen.addEventListener("click", function () { window.location.assign("/settings"); });
129
+ // Workbench <-> Models/Settings: each side remembers where the user was, so a
130
+ // round trip returns to the same filters, Task and management section.
131
+ function rememberWorkbench() { rememberPlace("workbench", window.location.pathname + window.location.search); }
132
+ el.rail.querySelectorAll("a[data-nav-area]").forEach(function (link) { link.addEventListener("click", rememberWorkbench); });
133
+ // The rail's Workbench entry on the workbench itself returns to the overview.
134
+ el.railWorkbench.addEventListener("click", function (event) {
135
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
136
+ event.preventDefault();
137
+ sidebarNav.closeDrawer();
138
+ selection.clearSelection();
139
+ });
140
+ window.addEventListener("pagehide", rememberWorkbench);
141
+ function linkAll() { linkPlaces(el.rail); linkPlaces(el.sidebar); }
142
+ window.addEventListener("pageshow", linkAll);
143
+ linkAll();
121
144
 
122
145
  // The URL names the catalog filters and the selected Task (with its section).
123
146
  function applyStateFromUrl() {
@@ -140,9 +163,10 @@ i18n.subscribe(function () {
140
163
 
141
164
  workspace.updateLayout();
142
165
  taskView.showOverview();
166
+ // The panel starts closed: name its owner without polling until it opens.
143
167
  conversation.open({ scope: "global", roleName: "operator" });
168
+ conversation.close();
144
169
  applyStateFromUrl();
145
- workspace.restoreDefaultMode();
146
170
  catalog.refresh();
147
171
  window.setInterval(function () { catalog.refresh({ quiet: true }); }, 5000);
148
172
  `;
@@ -2,5 +2,23 @@ export const MANAGE_SCRIPT = String.raw `
2
2
  import "/assets/js/app/settings.js";
3
3
  import "/assets/js/app/models.js";
4
4
  import { startManagement } from "/assets/js/layout/manageShell.js";
5
+ import { createSidebarNav } from "/assets/js/layout/sidebarNav.js";
6
+ import { isEditing } from "/assets/js/lib/dom.js";
7
+
8
+ // The section list behaves like the workbench task list: collapsible beside
9
+ // the content, a drawer (with the global rail) on narrow screens.
10
+ const sidebarNav = createSidebarNav({
11
+ el: { sidebar: document.querySelector(".manage-rail"), rail: document.querySelector(".app-rail"), scrim: document.querySelector("#sidebar-scrim"),
12
+ collapse: document.querySelector("#sidebar-collapse") },
13
+ relayout: function () {}
14
+ });
15
+ document.querySelectorAll("[data-sidebar-toggle]").forEach(function (button) {
16
+ button.addEventListener("click", sidebarNav.reveal);
17
+ });
18
+ document.addEventListener("keydown", function (event) {
19
+ if (document.querySelector("dialog[open]") || event.metaKey || event.ctrlKey || event.altKey) return;
20
+ if (event.key === "Escape" && sidebarNav.drawerOpen()) { sidebarNav.closeDrawer(); return; }
21
+ if (event.key === "[" && !isEditing(document.activeElement)) sidebarNav.toggle();
22
+ });
5
23
  startManagement();
6
24
  `;
@@ -74,6 +74,7 @@ function clearSelection(deps) {
74
74
  syncUrl(deps.state, !urlTaskId());
75
75
  deps.workspace.updateLayout();
76
76
  deps.taskView.showOverview();
77
+ deps.taskView.revealPage();
77
78
  deps.renderTaskList();
78
79
  deps.taskView.updateSessionTargets();
79
80
  }
@@ -96,9 +97,11 @@ function followUrl(deps, params) {
96
97
  return;
97
98
  }
98
99
  if (state.selected && !canLeave(deps)) { syncUrl(state, true); return; }
100
+ const leaving = !!state.selected;
99
101
  dropSelection(deps);
100
102
  deps.workspace.updateLayout();
101
103
  deps.taskView.showOverview();
104
+ if (leaving) deps.taskView.revealPage();
102
105
  }
103
106
 
104
107
  async function load(deps, taskId, navigate) {
@@ -120,7 +123,7 @@ async function load(deps, taskId, navigate) {
120
123
  state.detail = detail;
121
124
  state.detailKey = fingerprint(core);
122
125
  deps.taskView.render(navigate);
123
- if (navigate) deps.taskView.applySection(readQuery().get("section"));
126
+ if (navigate) { deps.taskView.applySection(readQuery().get("section")); deps.taskView.revealPage(); }
124
127
  observe(deps, taskId, detail);
125
128
  }
126
129
 
@@ -14,6 +14,7 @@ import { updateObservation } from "/assets/js/views/task/observation.js";
14
14
  import { renderDiscussion, discussionHasUnsent } from "/assets/js/views/dock/discussion.js";
15
15
  import { sameTarget } from "/assets/js/views/dock/terminal.js";
16
16
  import { setSectionParam } from "/assets/js/layout/router.js";
17
+ import { enter } from "/assets/js/lib/motion.js";
17
18
 
18
19
  // Only the observed identity participates: observation timestamps advance on
19
20
  // every poll and would otherwise re-render continuously.
@@ -29,7 +30,7 @@ function runtimeSignature(detail) {
29
30
  }
30
31
 
31
32
  // deps: { el, state, t, locale, api, toast, terminal, workspace,
32
- // selectTask, clearSelection, pickAttention, readPageSessions, refreshCatalog }
33
+ // selectTask, clearSelection, revealSidebar, pickAttention, readPageSessions, refreshCatalog }
33
34
  // el #detail, #center and the dock discussion
34
35
  // state reads selected, detail, detailKey and the catalog fields the
35
36
  // overview shows; writes activeTab
@@ -37,7 +38,8 @@ function runtimeSignature(detail) {
37
38
  // terminal the dock's native Session (views/dock/terminal)
38
39
  // workspace layout/workspace: dock toggles and opening Sessions
39
40
  // selectTask(taskId) an overview row was chosen (app/selection)
40
- // clearSelection() the Task page's back button (app/selection)
41
+ // clearSelection() the Task page's "Tasks" crumb (app/selection)
42
+ // revealSidebar() the page header's task-list switch (layout/sidebarNav)
41
43
  // pickAttention(kind), readPageSessions() overview actions (app/catalog)
42
44
  // refreshCatalog() quiet catalog refresh after a write; resolves when done
43
45
  export function createTaskView(deps) {
@@ -59,7 +61,10 @@ export function createTaskView(deps) {
59
61
  showOverview: function () { showOverview(deps); },
60
62
  updateSessionTargets: function () { updateSessionTargets(deps); },
61
63
  switchTab: function (tab, options) { switchTab(deps, tab, options); },
62
- applySection: function (section) { applySection(deps, section); }
64
+ applySection: function (section) { applySection(deps, section); },
65
+ // The page content after an explicit navigation (a Task opened, back to
66
+ // the overview) rises in; polling redraws never call this.
67
+ revealPage: function () { reveal(deps.el.detail.querySelector(":scope > .page")); }
63
68
  };
64
69
  return view.controller;
65
70
  }
@@ -143,9 +148,15 @@ function detailContext(view) {
143
148
  const api = deps.api;
144
149
  return {
145
150
  activeTab: deps.state.activeTab,
146
- onTab: view.controller.switchTab,
151
+ onTab: function (tab, options) {
152
+ const changed = tab !== deps.state.activeTab;
153
+ switchTab(deps, tab, options);
154
+ if (changed) reveal(deps.el.detail.querySelector(".tab-panel:not([hidden])"));
155
+ },
147
156
  onBack: deps.clearSelection,
157
+ revealSidebar: deps.revealSidebar,
148
158
  showDock: deps.workspace.toggleDock,
159
+ toggleOperator: deps.workspace.toggleOperator,
149
160
  openConversation: function (materials) {
150
161
  deps.workspace.openConversation({ scope: "task", taskId: deps.state.selected, roleName: "leader" }, materials);
151
162
  },
@@ -253,8 +264,14 @@ function showOverview(deps) {
253
264
  select: deps.selectTask,
254
265
  filterAttention: deps.pickAttention,
255
266
  readSessions: deps.readPageSessions,
256
- openOperator: deps.workspace.openOperator
267
+ toggleOperator: deps.workspace.toggleOperator,
268
+ revealSidebar: deps.revealSidebar
257
269
  });
270
+ deps.workspace.syncDockButtons();
271
+ }
272
+
273
+ function reveal(element) {
274
+ if (element && typeof enter === "function") enter(element, "rise");
258
275
  }
259
276
 
260
277
  function switchTab(deps, tab, options) {