From f07bb3b78dbab19e7c1b989b44c800af0085a0fb Mon Sep 17 00:00:00 2001
From: voidReq <100312344+voidReq@users.noreply.github.com>
Date: Thu, 22 Oct 2026 17:46:38 -0400
Subject: [PATCH] Settings in tabs: Status, Jobs, Applying, Assistant, Homelab,
App
Settings was one long page of 13 cards, and the job preferences (pool bars,
kind-of-work bonuses, tags) were only in Setup. Now:
- tabs at #/settings/, the same on the homelab's page
- Jobs shows Setup's preferences form; App shows Discord, automatic updates,
app icon (Setup's extras step, Discord split out for the homelab's page)
- Homelab: connection first; once in use, its ssh fields and the advanced
parts (code source, Mark-done links) fold away; NUauto on your homelab is
its own card
- a health poll redraws only the checks list, never a form being typed in
(it used to redraw the whole page every few seconds)
- Export for Bitwarden only when Bitwarden is off (else its Add is the way)
- clearer labels and help text; "Settings > X" messages name the tab
Co-Authored-By: Claude Opus 5.5
---
CLAUDE.md | 4 +-
docs/GUI.md | 76 ++++++-----
docs/REMOTE.md | 6 +-
src/nuauto/assist.py | 2 +-
src/nuauto/gui.py | 25 ++--
src/nuauto/gui_static/app.js | 238 ++++++++++++++++++++++-----------
src/nuauto/gui_static/setup.js | 17 ++-
src/nuauto/health.py | 2 +-
src/nuauto/link.py | 2 +-
src/nuauto/onboard.py | 6 +-
src/nuauto/review.py | 2 +-
src/nuauto/window.py | 2 +-
tests/screens.py | 3 +-
tests/test_gui.py | 3 +
tests/test_homelab_app.py | 16 ++-
tests/test_link.py | 4 +-
16 files changed, 266 insertions(+), 142 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index dc1ba6f..08968cb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -46,7 +46,7 @@ GIT
HARD LIMITS
- Under 100 applications total.
- Weekly cap, enforced in code (count sent rows (Applied, Accepted, Rejected) with a date in the current week; refuse to
- run past the limit). sheet.py: MAX_PER_WEEK = 11 is the default, changeable in the GUI (Settings > Applications per week, local_config.json "max_per_week", 1-30; sheet.max_per_week()), MAX_TOTAL = 99 (decided 2026-10-03). A week =
+ run past the limit). sheet.py: MAX_PER_WEEK = 11 is the default, changeable in the GUI (Settings > Applying > Applications per week, local_config.json "max_per_week", 1-30; sheet.max_per_week()), MAX_TOTAL = 99 (decided 2026-10-03). A week =
fixed 7-day periods from local_config.json "week_start" (sheet.week_window); without it, the last
7 days. Mine: week_start 2026-10-06 (reset because I started applying late), then every 7 days.
Approved rows beyond the cap just wait for the next week.
@@ -79,7 +79,7 @@ SAFETY RULES (most important)
profile. The script selects only the exact "resume_label" saved in profile.json and
never uploads anything.
(Added 2026-10-12, my request.) The company-site agent may also upload my unofficial transcript (local_config.json
- "transcript_path"; GUI Settings > Your files keeps it as local/transcript.pdf; the homelab gets a copy there), only where
+ "transcript_path"; GUI Settings > Applying > Your files keeps it as local/transcript.pdf; the homelab gets a copy there), only where
a page asks for one. (2026-10-20) The resume and the transcript given in the GUI are NUauto's own copies in local/,
pushed to the homelab at once (docs/GUI.md, YOUR FILES).
7. (Changed 2026-10-03; GUI added 2026-10-05.) `nuauto apply` submits every Approved row, one at a
diff --git a/docs/GUI.md b/docs/GUI.md
index 2bc9fd7..533464c 100644
--- a/docs/GUI.md
+++ b/docs/GUI.md
@@ -21,7 +21,7 @@ WHAT IT IS
devices (your phone). Every screen is the same; the page knows where it runs (state.where) and shows what only the
laptop can do (state.laptop_only: logins, terminal windows, the laptop's files, Bitwarden, setup's file steps, Quit) as
"On your laptop" with the reason; the server refuses those there (the Bitwarden card is left out). What it adds there:
- each assistant session's screen (#/assistant/), the queue in Apply (each row's chip, the Assistants tab), Settings > This homelab (paired
+ each assistant session's screen (#/assistant/), the queue in Apply (each row's chip, the Assistants tab), Settings > Homelab > This homelab (paired
devices). Without a homelab none of it exists, and the laptop's app is unchanged.
- Each thing in one place (2026-10-14), with or without a homelab: Today has one list of what only you can do (the
assistants waiting for you first, each item opening where you do it: no second Mark done or Open job there); Apply
@@ -156,36 +156,48 @@ SCREENS
that is another entry's wording, naming both
Ctrl+F (Cmd+F on a Mac) goes to the screen's own search box: Apply, Review, Answers ([data-find]); elsewhere, and in
a dialog, the browser's own find
- Settings every health check (run them now), logins (Google, NUworks, Claude Code, and Gmail: the read-only login
- for the assistant's email codes, with a link to turn the Gmail API on), resume label, applications per
- week (the weekly cap), week start; Your files (the resume and the transcript: YOUR FILES);
- the company-site assistant: account email, effort (low / medium / high), assistants at once (1-3, on each
- machine; no homelab needed), the folders it may read, your writeups (assist_writeups: the first place it
- looks for essays), its job-site logins (how many; Export for Bitwarden: the import file in ~/Downloads,
- mode 600, and how to import it); Summer internships (optional; local_config "internships",
- docs/PIPELINE.md): on / off, the term as the lists name it (a new year's term moves the lists' addresses
- with it), the match % to enter the pool (empty: the co-ops' bar), each list's address (empty: not read);
- Password manager (optional, for anyone; local_config "password_manager": "bitwarden"): Use Bitwarden / Stop
- using it, Install it (npm @bitwarden/cli, then bw login, in a terminal window), Add N to Bitwarden (a terminal
- window: bw unlock asks your master password itself, then `nuauto accounts export --bitwarden`), Your own logins
- (words their addresses contain, "bitwarden_import") + Import my logins (a terminal: unlock, then `bw list items
- --search | nuauto accounts import - --only ` per word), Check now; Homelab (a source install,
- never the packaged app; onboard.homelab_link): its ssh name and folder, Check (over ssh, read-only:
- onboard.homelab_probe: its hostname, Tailscale address, NUauto / Claude / timers / lingering / the
- assistant's tools there), Use this homelab (local_config's server fields; NUauto then needs a restart: the
- card offers Quit), Set it up (a terminal window: `nuauto onboard homelab-setup`, docs/DEPLOY.md's steps,
- each asked first; sudo asks for the homelab's password there), Log in to Claude there (ssh -t ... claude),
- Stop using it (local mode again); with one in use, its code (From GitHub's main / From this computer
- instead: deploy_from_git, a run of `nuauto onboard homelab-deploy on|off`: the code pushed once, the
- deploy timer turned on or off; your fork's address, deploy_repo) and the Mark-done links' public address
- (web_base_url; the homelab's web page restarts with it); in the same card, NUauto on your homelab (homelab mode only, docs/REMOTE.md): its page on /
- off (remote_app), its assistant on / off (remote_assist, which needs the page: both pushed to the homelab,
- nuauto-web restarted), Start sessions by itself, one switch for NUworks rows and one for Other jobs rows
- (remote_auto {"nuworks", "other"}), its hours (remote_hours), assistants at once (assist_slots), Pair this
- computer, Open it; setup, logs, quit. On the homelab's page one This homelab card: Paired devices (each
- one, "this one", Unpair, Make a pairing link for another device) and its assistant's queue settings (no
- on / off: the laptop's); the email and effort only (folders, export: on your laptop); Your files read-only; no
- Password manager card (Bitwarden unlocks on the laptop), no Quit
+ Settings in tabs (#/settings/; plain #/settings: the last tab this session, else Status), the same on the
+ homelab's page (2026-10-22, my request: it was one long page). A health poll redraws only Status's list
+ of checks, never a form being typed in.
+ Status: every health check (run them now); logins (Google, NUworks, Claude Code, and Gmail: the read-only
+ login for the assistant's email codes, with a link to turn the Gmail API on).
+ Jobs: What you're looking for (Setup's preferences step, the same form and save: term, year, match bars,
+ kinds of work with their bonuses and lower bars, tags; saving rebuilds the pool); Summer internships
+ (optional; local_config "internships", docs/PIPELINE.md): on / off, the term as the lists name it (a new
+ year's term moves the lists' addresses with it), the match % to enter the pool (empty: the co-ops' bar),
+ each list's address (empty: not read).
+ Applying: applications per week (the weekly cap), week start, the resume NUauto picks (resume label), a
+ link to your sheet (another sheet: Setup); Your files (the resume and the transcript: YOUR FILES).
+ Assistant: the company-site assistant: email for job-site accounts, effort (low / medium / high),
+ assistants at once (1-3, on each computer; no homelab needed), the folders it may read, writeups
+ (assist_writeups: the first place it looks for essays), its job-site logins (how many; with Bitwarden off,
+ Export for Bitwarden: the import file in ~/Downloads, mode 600, and how to import it; with it on, the
+ Password manager card's Add is the way in). Password manager (optional, for anyone; local_config
+ "password_manager": "bitwarden"): Use Bitwarden / Stop using it, Install it (npm @bitwarden/cli, then bw
+ login, in a terminal window), Add N to Bitwarden (a terminal window: bw unlock asks your master password
+ itself, then `nuauto accounts export --bitwarden`), Your own logins (words their addresses contain,
+ "bitwarden_import") + Import my logins (a terminal: unlock, then `bw list items --search | nuauto
+ accounts import - --only ` per word), Check now.
+ Homelab (a source install, never the packaged app; onboard.homelab_link): not in use: its ssh name and
+ folder, Check (over ssh, read-only: onboard.homelab_probe: its hostname, Tailscale address, NUauto /
+ Claude / timers / lingering / the assistant's tools there), Use this homelab (local_config's server
+ fields; NUauto then needs a restart: the card offers Quit). In use: Set it up (a terminal window:
+ `nuauto onboard homelab-setup`, docs/DEPLOY.md's steps, each asked first; sudo asks for the homelab's
+ password there), Log in to Claude there (ssh -t ... claude), Stop using it (local mode again); folded
+ away: another homelab's ssh name and folder, and Advanced: its code (From GitHub's main / From this
+ computer instead: deploy_from_git, a run of `nuauto onboard homelab-deploy on|off`: the code pushed once,
+ the deploy timer turned on or off; your fork's address, deploy_repo) and the Mark-done links' public
+ address (web_base_url; the homelab's web page restarts with it). Its own card, NUauto on your homelab
+ (homelab mode only, docs/REMOTE.md): its page on / off (remote_app), its assistant on / off
+ (remote_assist, which needs the page: both pushed to the homelab, nuauto-web restarted), Start sessions
+ by itself, one switch for NUworks rows and one for Other jobs rows (remote_auto {"nuworks", "other"}),
+ its hours (remote_hours), Pair this computer, Open it, This app (link.py).
+ App: Discord, automatic updates, app icon (Setup's extras step, the same body); Setup (open it, Run a
+ self-test, logs); About (version, folders, Quit).
+ On the homelab's page: Homelab is This homelab (Paired devices: each one, "this one", Unpair, Make a
+ pairing link for another device) and Its assistant (its queue settings; no on / off: the laptop's);
+ Assistant: the email, effort and assistants at once only (folders, export: on your laptop), no Password
+ manager card (Bitwarden unlocks on the laptop); Applying: Your files read-only; App: Discord only, no Quit
Setup the wizard (below); opens by itself until setup is done (never on the homelab's page: setup is the
laptop's; there it says so, and the steps it can do are preferences, Discord and the resume label)
Past runs logs/ folders: actions.log and the screenshots before and after Submit
@@ -417,7 +429,7 @@ API (JSON; all need the cookie; POSTs need X-NUauto: 1)
POST /api/quit
YOUR FILES (2026-10-20, my request: "a way to upload a resume and a transcript into the gui")
-- The resume (Setup's resume step, or Settings > Your files) and the transcript (Settings > Your files; optional, Remove
+- The resume (Setup's resume step, or Settings > Applying > Your files) and the transcript (Settings > Applying > Your files; optional, Remove
takes it out) are given to NUauto by dropping or choosing the PDF in the page. NUauto keeps its own copy,
local/resume.pdf / local/transcript.pdf (mode 600; onboard.save_document: a PDF, up to 10 MB, the resume with text
Claude can read), and points resume_path / transcript_path at it. So changing the PDF you had elsewhere changes
diff --git a/docs/REMOTE.md b/docs/REMOTE.md
index 1d233ea..75b5a2c 100644
--- a/docs/REMOTE.md
+++ b/docs/REMOTE.md
@@ -74,12 +74,12 @@ Off again: Turn off in the same place (the homelab's page stops; its copies stay
PAIR YOUR PHONE (once per device)
nuauto review pair phone (laptop or homelab; --discord also posts the link to your Discord)
- or, from a device already paired: the homelab page's Settings > Paired devices > Make a pairing link.
+ or, from a device already paired: the homelab page's Settings > Homelab > Paired devices > Make a pairing link.
Open the link on the phone, Tailscale on, and press Pair (within 10 minutes; the link works once). Pair the browser
you will use: a link tapped in Discord opens in Discord's own browser, which keeps its own cookies (copy it into
Safari / Chrome instead). Each browser is paired once; then bookmark http://:8766/ there (or Add to
Home Screen). The laptop's browser: Settings > Homelab > Pair this computer.
- nuauto review devices / nuauto review unpair |--all (or Settings > Paired devices > Unpair)
+ nuauto review devices / nuauto review unpair |--all (or Settings > Homelab > Paired devices > Unpair)
THE QUEUE (remote.py; nuauto-assist.timer every 10 minutes runs `nuauto assist auto`)
- On with "remote_auto" (and remote_assist), per sheet tab: true = both; {"nuworks": true, "other": false} = each on
@@ -233,7 +233,7 @@ THE LAPTOP'S LINK (link.py, 2026-10-15, my request: "as much support and smooth
- Settings > Homelab > This app: Link this app makes the laptop's NUauto one of the page's paired devices: a one-time
pairing code from the homelab over ssh (`nuauto review pair` there), used up at once by the laptop's server. Its key:
local/homelab_link.json (600), never shown, never synced; the device is named "NUauto on " in the
- page's Paired devices. Unlink forgets it here and unpairs it there; unpaired there (Settings > This homelab), the app
+ page's Paired devices. Unlink forgets it here and unpairs it there; unpaired there (Settings > Homelab > This homelab), the app
forgets it at its next request and shows "not linked" until you press Link again.
- The laptop's server makes every request itself, to the page's own address (Tailscale), as that device; the laptop's
page only ever talks to the laptop's server (/api/homelab/...). What the homelab's page refuses, it refuses here too,
diff --git a/src/nuauto/assist.py b/src/nuauto/assist.py
index 4ab2da2..c9809d7 100644
--- a/src/nuauto/assist.py
+++ b/src/nuauto/assist.py
@@ -2096,7 +2096,7 @@ def main(argv):
from nuauto import sheet
if config.IS_SERVER and not config.REMOTE_APP:
sys.exit("The NUworks side runs on the laptop (you at a terminal or the GUI); on the homelab only from "
- "NUauto's page there (Settings > NUauto on your homelab, on the laptop).")
+ "NUauto's page there (Settings > Homelab > NUauto on your homelab, on the laptop).")
apply.refuse_unattended(ui) # a real NUworks submit: you at a terminal or the GUI, never an agent
if nuworks_session():
sys.exit(NUWORKS_BUSY)
diff --git a/src/nuauto/gui.py b/src/nuauto/gui.py
index b90116e..b7ae0ba 100644
--- a/src/nuauto/gui.py
+++ b/src/nuauto/gui.py
@@ -1050,7 +1050,7 @@ def act(body):
"only your logins whose address has: " + ", ".join(texts))
elif kind == "review_pair": # Pair this computer: a one-time link from the homelab, opened here (you press Pair)
if not homelab_review():
- raise Refused("NUauto on your homelab is off (Settings > NUauto on your homelab).")
+ raise Refused("NUauto on your homelab is off (Settings > Homelab > NUauto on your homelab).")
from nuauto import review
link = review.pair_link_from_homelab("this computer")
if not link:
@@ -1079,7 +1079,7 @@ def act(body):
return {"message": f"Linked as {name}: the homelab's sessions and runs open in this app."}
elif kind == "pair_device": # (homelab) a one-time link for another of your devices: you open it there, press Pair
if not REMOTE:
- raise Refused("Pairing is for NUauto on your homelab (Settings > NUauto on your homelab).")
+ raise Refused("Pairing is for NUauto on your homelab (Settings > Homelab > NUauto on your homelab).")
from nuauto import review
name = " ".join(str(args.get("name") or "").split())[:40] or "device"
return {"link": review.make_pairing(name), "minutes": review.PAIR_TTL // 60}
@@ -1149,7 +1149,7 @@ def remote_assist_start(args):
"""(homelab) Start now: one row's assistant session in tmux (review.start_now: a free slot, its job site free, the
caps, its records). Its screen here is #/assistant/ once it runs."""
if not config.REMOTE_ASSIST:
- raise Refused("The assistant on the homelab is off (the laptop's Settings > NUauto on your homelab).")
+ raise Refused("The assistant on the homelab is off (the laptop's Settings > Homelab > NUauto on your homelab).")
from nuauto import review
started, message = review.start_now({"tab": "other" if args.get("tab") == "other" else "jobs",
"row": str(args.get("row"))}, device_name())
@@ -1817,7 +1817,7 @@ def settings_put(body):
updates["bitwarden_import"] = [t.strip() for t in texts if t.strip()]
remote_keys = {"remote_assist", "remote_app", "remote_auto", "remote_hours"} & set(body)
if remote_keys and REMOTE and not config.REMOTE_ASSIST:
- raise Refused("The assistant on this homelab is off (the laptop's Settings > NUauto on your homelab).")
+ raise Refused("The assistant on this homelab is off (the laptop's Settings > Homelab > NUauto on your homelab).")
if remote_keys and not REMOTE and not onboard.homelab_assistant()["available"]:
raise Refused("NUauto on a homelab needs a homelab (Settings > Homelab, or server_hostname in local_config.json; "
"docs/DEPLOY.md).")
@@ -1942,7 +1942,7 @@ def setup_post(body):
a = body.get("action")
if REMOTE and a not in REMOTE_SETUP:
raise Refused("This setup step is done on your laptop (it picks a file, logs in or makes something there). "
- "Open NUauto on your laptop: Settings > Open setup.")
+ "Open NUauto on your laptop: Settings > App > Open setup.")
try:
if a == "ack":
onboard.save_config({"tos_ack": date.today().isoformat()})
@@ -2035,7 +2035,7 @@ class NotFound(Exception):
REMOTE_POSTS_OFF = {"/api/quit": "NUauto on your homelab stays on (it is part of nuauto-web).",
"/api/logins/export": "The logins file for Bitwarden is written on your laptop (Settings there).",
"/api/files": "Your resume and transcript are kept on your laptop: give NUauto there the new one "
- "(Settings > Your files). It copies them here at once."}
+ "(Settings > Applying > Your files). It copies them here at once."}
REQUEST = threading.local() # the paired device a request comes from (its name goes in the run logs)
APP_SCREEN_SLOT = 0 # the virtual screen of a run you watch here (Apply, the NUworks side); 1-3: the assistant's
_TERMINAL = "It runs in a terminal window on your laptop (you type there, never into this page)."
@@ -2048,7 +2048,7 @@ class NotFound(Exception):
"It copies the session here.",
"login_claude": "Claude Code logs in in a terminal. For the homelab's: in NUauto on your laptop, Settings > Homelab > "
"Log in to Claude there.",
- "review_pair": "Pair this computer is the laptop's button. Here: Settings > Paired devices > Pair another device.",
+ "review_pair": "Pair this computer is the laptop's button. Here: Settings > Homelab > Paired devices > Pair another device.",
**{k: "Bitwarden asks for your master password in a terminal window on your laptop: do this from NUauto there."
for k in ("bitwarden_add", "bitwarden_import", "bitwarden_login", "bitwarden_install")},
}
@@ -2198,7 +2198,7 @@ def page(title, body):
NOT_PAIRED = ("This browser is not paired with NUauto on your homelab. Make a pairing link (on your laptop: Settings > "
- "NUauto on your homelab > Pair this computer; for a phone, `nuauto review pair --discord`) and open it in "
+ "Homelab > NUauto on your homelab > Pair this computer; for a phone, `nuauto review pair --discord`) and open it in "
"this browser. Paired a moment ago? A link opened from Discord may have opened in Discord's own browser, "
"which keeps its own cookies: copy the link into your phone's browser (Safari, Chrome) and pair there.")
@@ -2218,7 +2218,7 @@ def paired_page(ok):
"for once: to come back, open this address again (bookmark it, or Add to Home Screen). Discord's "
"messages link here too.
Open NUauto
")
return page("Not paired", "Not paired
That link is used up or expired (they work once, for "
- "10 minutes). Make a new one: on your laptop, Settings > NUauto on your homelab > Pair this computer; "
+ "10 minutes). Make a new one: on your laptop, Settings > Homelab > NUauto on your homelab > Pair this computer; "
"or nuauto review pair.
")
@@ -2579,7 +2579,8 @@ def ask_instance_to_open(lock):
return False
-SCREENS = ["home", "review", "apply", "sheet", "insights", "answers", "settings", "setup", "logs"]
+SETTINGS_SCREENS = ["settings/status", "settings/jobs", "settings/applying", "settings/assistant", "settings/homelab", "settings/app"] # each Settings tab its own picture
+SCREENS = ["home", "review", "apply", "sheet", "insights", "answers", *SETTINGS_SCREENS, "setup", "logs"]
# Review's internship list (when internships are on), as extra pictures: the first card, the one whose posting could not
# be read (a kind-of-work choice that holds only it), and a search (every match as its own card). Each is made by
@@ -2611,7 +2612,7 @@ def review_intern_shots(page, base, folder, scheme, label):
return made
-REMOTE_SCREENS = ["home", "apply", "company", "assistant/1", "assistant/2", "assistant/3", "settings", "setup"]
+REMOTE_SCREENS = ["home", "apply", "company", "assistant/1", "assistant/2", "assistant/3", *SETTINGS_SCREENS, "setup"]
def screenshots(folder, url, remote=False):
@@ -2697,7 +2698,7 @@ def main(argv):
return main_remote_demo(argv)
if config.IS_SERVER:
sys.exit("nuauto gui runs on your laptop. On the homelab, NUauto's page for your phone runs inside nuauto web "
- "(Settings > NUauto on your homelab, on the laptop).")
+ "(Settings > Homelab > NUauto on your homelab, on the laptop).")
VIEW = "--view" in argv
if VIEW and config.DEMO:
sys.exit("--view is for your real data; --demo is already fake. Use one of them.")
diff --git a/src/nuauto/gui_static/app.js b/src/nuauto/gui_static/app.js
index 54f6217..c1b3d48 100644
--- a/src/nuauto/gui_static/app.js
+++ b/src/nuauto/gui_static/app.js
@@ -144,7 +144,7 @@ function linkOut(url, text) {
function plural(n, word) { return `${n} ${word}${n === 1 ? "" : "s"}`; }
// Your resume and transcript: a PDF you drop or choose goes to NUauto (/api/files), which keeps its own copy in local/
-// and pushes it to the homelab. Setup's resume step and Settings > Your files.
+// and pushes it to the homelab. Setup's resume step and Settings > Applying > Your files.
const MAX_PDF = 10000000; // onboard.MAX_DOCUMENT
async function sendPdf(kind, file) {
if (file.size > MAX_PDF) throw new Error(`${file.name} is over 10 MB.`);
@@ -373,7 +373,7 @@ function healthPopover(c) {
el("div", { class: "row" }, fixButton(now, "btn small primary"),
groups.length && !(S.state && S.state.view) ? el("button", { class: "btn small", testid: `recheck-${c.id}`, disabled: checking,
text: checking ? "Checking…" : "Check now", onclick: recheck }) : null,
- el("a", { href: "#/settings", class: "btn small ghost", text: "All checks", onclick: () => pop.remove() })));
+ el("a", { href: "#/settings/status", class: "btn small ghost", text: "All checks", onclick: () => pop.remove() })));
}
async function recheck(e) {
e.stopPropagation();
@@ -1234,7 +1234,7 @@ screens.apply = async (view) => {
return el("div", { class: "card", testid: "apply-week" },
el("div", { class: "row between" }, el("div", {},
el("h2", { text: w.room ? `You can apply to ${plural(w.room, "more job")} this week` : "Weekly limit reached" }),
- el("p", { class: "small muted" }, `${w.applied} of ${w.max} ${w.label} · ${w.total} of ${w.max_total} in total` + (w.next ? ` · new week ${w.next}` : "") + " · ", el("a", { href: "#/settings", text: "change the limit" }))),
+ el("p", { class: "small muted" }, `${w.applied} of ${w.max} ${w.label} · ${w.total} of ${w.max_total} in total` + (w.next ? ` · new week ${w.next}` : "") + " · ", el("a", { href: "#/settings/applying", text: "change the limit" }))),
drawnRunning ? null : el("div", { class: "row" },
runOnSwitch(),
el("label", { class: "small muted" }, "At most ", atMost),
@@ -1894,57 +1894,89 @@ screens.assistant = async (view) => {
};
// ---- Settings
+// Settings, in tabs (#/settings/), the same on the homelab's page: Status (the health checks, your logins), Jobs
+// (what you're looking for, Summer internships), Applying (the weekly limit, the resume NUworks gets, your sheet and
+// files), Assistant (the company-site assistant, your password manager), Homelab, App (notifications, setup, about).
+const SETTINGS_TABS = [["status", "Status"], ["jobs", "Jobs"], ["applying", "Applying"], ["assistant", "Assistant"],
+ ["homelab", "Homelab"], ["app", "App"]];
+
screens.settings = async (view) => {
let d;
+ let setup = null; // /api/setup's steps: the Jobs and App tabs show some of their bodies (the same forms as Setup)
+ let tab = S.param || S.settingsTab || "status";
+ let healthList = null; // the Status tab's list of checks: a health poll redraws only it, never a form you're typing in
+ const tabs = el("div", { class: "tabs", role: "tablist", "aria-label": "Settings" });
+ const pane = el("div", { role: "tabpanel", id: "settings-pane", testid: "settings-pane" });
+ fill(view, tabs, pane);
async function reload() {
- try { d = await api.get("/api/settings"); S.logsDir = d.paths.logs; } catch (e) { fill(view, el("div", { class: "note fail", text: e.message })); return; }
+ try { d = await api.get("/api/settings"); S.logsDir = d.paths.logs; } catch (e) { fill(pane, el("div", { class: "note fail", text: e.message })); return; }
draw();
}
- function draw() {
- const s = d.settings;
- const label = el("input", { type: "text", value: d.resume_label, "aria-label": "Resume label", testid: "set-resume-label" });
- const week = el("input", { type: "date", value: s.week_start || "", "aria-label": "Week start", testid: "set-week-start" });
- const cap = el("input", { type: "number", min: "1", max: String(d.max_per_week_ceiling), step: "1", value: String(d.max_per_week),
- "aria-label": "Applications per week", testid: "set-max-per-week" });
- fill(view,
- el("div", { class: "card", testid: "all-checks" }, el("div", { class: "row between" }, el("h2", { text: "Health" }),
- el("button", { class: "btn small", text: "Run all checks now", testid: "btn-run-checks", onclick: async () => {
- await api.post("/api/health/run", { groups: ["quick", "sheet", "claude", "firefox", "discord", "homelab", "bitwarden", "nuworks"] }).catch(fail);
- toast("Checking…"); setTimeout(pollHealth, 1500); setTimeout(pollHealth, 6000); } })),
- el("ul", { class: "list" }, ...S.checks.map((c) => el("li", { testid: `check-${c.id}` }, el("div", { class: "what" },
- el("span", { class: `chip ${c.status}`, text: SHORT[c.status] || c.status }), " ", el("b", { text: c.title }), " ",
- el("span", { class: "muted", text: c.detail }), el("div", { class: "small muted", text: `checked ${ago(c.at)}` })), fixButton(c))))),
- el("div", { class: "grid2" },
- el("div", { class: "card stack" }, el("h2", { text: "Logins" }),
- el("div", { class: "row between" }, el("span", { text: "Google" }), el("button", { class: "btn small", text: d.google_login ? "Log in again" : "Log in", onclick: () => action("login_google") })),
- el("div", { class: "row between" }, el("span", { text: "NUworks" }), el("div", { class: "row" },
- el("button", { class: "btn small", text: "Check now", onclick: () => { action("check_nuworks"); toast("Checking NUworks in the background…"); } }),
- el("button", { class: "btn small", text: "Log in", onclick: () => action("login_nuworks") }))),
- el("div", { class: "row between" }, el("span", { text: "Claude Code" }), el("button", { class: "btn small", text: "Log in", onclick: () => action("login_claude") })),
- el("div", { class: "row between", testid: "gmail-login" }, el("div", {}, el("span", { text: "Gmail (optional)" }),
- el("div", { class: "small muted", text: d.gmail.ready ? (d.gmail.days_left != null ? `read-only; ${Math.max(0, d.gmail.days_left)} days left` : "read-only") :
- "read-only, for the assistant's email codes and links" })),
- el("button", { class: "btn small", text: d.gmail.ready ? "Log in again" : "Log in", testid: "btn-login-gmail", onclick: () => action("login_gmail") })),
- d.gmail.ready ? null : el("p", { class: "small muted" }, "Gmail first needs its API turned on in your Google Cloud project: ",
- linkOut("https://console.cloud.google.com/apis/library/gmail.googleapis.com", "Gmail API"), ". Use the account email's inbox (below)."),
- el("p", { class: "small muted", text: "Google logins last 7 days (Testing-mode apps); NUauto warns you a day before. Log in to Google again renews Gmail's too." })),
- el("div", { class: "card stack" }, el("h2", { text: "Applying" }),
- el("label", { class: "field" }, "NUworks resume label", el("span", { class: "muted small", text: "Exactly as in the Apply popup's Resume dropdown." }), label),
- el("label", { class: "field" }, "Applications per week", el("span", { class: "muted small", text: `The most NUauto will submit in one week (1 to ${d.max_per_week_ceiling}). It stops at this number; Approved jobs wait for the next week.` }), cap),
- el("label", { class: "field" }, "Weekly limit counts from", el("span", { class: "muted small", text: "Fixed 7-day weeks from this day. Empty: any rolling 7 days." }), week),
- el("div", {}, el("button", { class: "btn primary", text: "Save", testid: "settings-save", onclick: async () => {
- try { d = await api.post("/api/settings", { resume_label: label.value, week_start: week.value, max_per_week: cap.value }); toast("Saved."); pollState(); draw(); } catch (e) { fail(e); } } })))),
- filesCard(),
- assistantCard(),
- el("div", { class: "card stack", testid: "intern-settings" }, el("h2", { text: "Summer internships" }),
- internshipsPanel(d.internships, (r) => { if (r) { d = r; draw(); } else reload(); })),
+ let setupLoading = null; // one /api/setup at a time (it reads your sheet: seconds), shared by every draw waiting on it
+ function loadSetup() {
+ return setupLoading || (setupLoading = api.get("/api/setup").then((r) => { setup = r; }, (e) => { setup = { error: e.message }; })
+ .finally(() => { setupLoading = null; }));
+ }
+ // one Setup step's body ("as": another body with that step's data, like Discord alone from the extras step)
+ function stepBody(id, as = id) {
+ if (setup.error) return el("div", { class: "note fail", text: setup.error });
+ const s = (setup.steps || []).find((x) => x.id === id);
+ return s ? el("div", { class: "stack" }, ...window.NUautoSetupBody(as, s, () => { setup = null; draw(); })) : null;
+ }
+ const shown = () => SETTINGS_TABS.filter(([k]) => k !== "homelab" || onHomelab() || (d.homelab && d.homelab.available));
+ function pick(k) {
+ tab = k;
+ S.settingsTab = k;
+ history.replaceState(null, "", `#/settings/${k}`);
+ draw();
+ }
+ let drawn = 0; // the newest draw wins: one still waiting for /api/setup must not replace a tab drawn since
+ async function draw() {
+ const mine = ++drawn;
+ const list = shown();
+ if (!list.some(([k]) => k === tab)) tab = "status";
+ fill(tabs, ...list.map(([k, label]) => el("button", { role: "tab", testid: `settings-tab-${k}`, "aria-selected": String(tab === k),
+ "aria-controls": "settings-pane", text: label, onclick: () => pick(k) })));
+ healthList = null;
+ if ((tab === "jobs" || tab === "app") && (!setup || setup.error)) { // (an error: tried again on the next draw)
+ fill(pane, el("p", { class: "row muted" }, el("span", { class: "spinner" }), "Loading…"));
+ await loadSetup();
+ if (mine !== drawn) return;
+ }
+ fill(pane, ...PANES[tab]());
+ }
+ function drawHealth() {
+ if (healthList) fill(healthList, ...S.checks.map((c) => el("li", { testid: `check-${c.id}` }, el("div", { class: "what" },
+ el("span", { class: `chip ${c.status}`, text: SHORT[c.status] || c.status }), " ", el("b", { text: c.title }), " ",
+ el("span", { class: "muted", text: c.detail }), el("div", { class: "small muted", text: `checked ${ago(c.at)}` })), fixButton(c))));
+ }
+ const PANES = {
+ status: () => {
+ healthList = el("ul", { class: "list" });
+ drawHealth();
+ return [
+ el("div", { class: "card", testid: "all-checks" }, el("div", { class: "row between" }, el("h2", { text: "Health" }),
+ el("button", { class: "btn small", text: "Run all checks now", testid: "btn-run-checks", onclick: async () => {
+ await api.post("/api/health/run", { groups: ["quick", "sheet", "claude", "firefox", "discord", "homelab", "bitwarden", "nuworks"] }).catch(fail);
+ toast("Checking…"); setTimeout(pollHealth, 1500); setTimeout(pollHealth, 6000); } })), healthList),
+ loginsCard()];
+ },
+ jobs: () => [
+ el("div", { class: "card stack", testid: "prefs-settings" }, el("h2", { text: "What you're looking for" }),
+ el("p", { class: "small muted", text: "Your term and year, the match a job needs to enter your pool, and the kinds of work with their ranking bonuses. Saving rebuilds your pool at once." }),
+ stepBody("preferences")),
+ internCard()],
+ applying: () => [applyingCard(), filesCard()],
+ assistant: () => [assistantCard(),
// Bitwarden unlocks in a terminal window on the laptop: its card is the laptop's only
onHomelab() ? null : el("div", { class: "card stack", testid: "pm-settings" }, el("h2", { text: "Password manager" }),
- passwordManagerPanel(d.password_manager, (r) => { if (r) { d = r; draw(); } else reload(); })),
- homelabCard(),
+ passwordManagerPanel(d.password_manager, (r) => { if (r) { d = r; draw(); } else reload(); }))],
+ homelab: () => homelabCards(),
+ app: () => [
+ el("div", { class: "card stack", testid: "extras-settings" }, stepBody("extras", onHomelab() ? "discord" : "extras")),
el("div", { class: "card stack" }, el("h2", { text: "Setup" }),
- el("p", { class: "small muted", text: onHomelab() ? "Setup is done in NUauto on your laptop (the homelab gets its settings from there). Your preferences can be changed here too." :
- "Google client, sheet, resume, NUworks, preferences, notifications: each step checked." }),
+ el("p", { class: "small muted", text: onHomelab() ? "Setup is done in NUauto on your laptop (the homelab gets its settings from there)." :
+ "Every step again, each checked: Google, your sheet, resume, NUworks, preferences, notifications." }),
el("div", { class: "row" }, el("a", { class: "btn", href: "#/setup", text: "Open setup" }),
el("button", { class: "btn", text: "Run a self-test", testid: "btn-selftest", title: "Demo mode end to end in a hidden browser: nothing real is touched",
onclick: () => action("selftest") }),
@@ -1954,24 +1986,63 @@ screens.settings = async (view) => {
d.tools.claude ? [" · claude: ", el("code", { text: d.tools.claude })] : " · claude not found"),
onHomelab() ? el("p", { class: "small muted", text: "NUauto on your homelab is always on (it is part of nuauto-web there)." }) :
el("div", {}, el("button", { class: "btn danger", text: "Quit NUauto", testid: "btn-quit", onclick: () => confirmBox("Quit NUauto?",
- "Closes this window's server. A running job is stopped first (like Ctrl+C).", "Quit", async () => { await api.post("/api/quit", {}).catch(() => {}); gone("NUauto has quit. Start it again from your apps menu, or run nuauto gui."); }, { danger: true }) }))));
- }
- // (the homelab's page) the devices paired with it: each one's cookie is its key (the homelab keeps only its hash)
- // Everything about the homelab in one card. The laptop: which homelab (connection, setup), then NUauto there (its
- // page, its assistant). The homelab's own page: the devices paired with it, and its assistant's queue.
- function homelabCard() {
+ "Closes this window's server. A running job is stopped first (like Ctrl+C).", "Quit", async () => { await api.post("/api/quit", {}).catch(() => {}); gone("NUauto has quit. Start it again from your apps menu, or run nuauto gui."); }, { danger: true }) })))],
+ };
+ // redrawn by itself after a change, so the preferences form above keeps what you typed
+ function internCard() {
+ const card = el("div", { class: "card stack", testid: "intern-settings" }, el("h2", { text: "Summer internships" }),
+ internshipsPanel(d.internships, async (r) => {
+ if (r) d = r;
+ else try { d = await api.get("/api/settings"); } catch (e) { fail(e); return; }
+ card.replaceWith(internCard());
+ }));
+ return card;
+ }
+ function loginsCard() {
+ return el("div", { class: "card stack" }, el("h2", { text: "Logins" }),
+ el("div", { class: "row between" }, el("span", { text: "Google" }), el("button", { class: "btn small", text: d.google_login ? "Log in again" : "Log in", onclick: () => action("login_google") })),
+ el("div", { class: "row between" }, el("span", { text: "NUworks" }), el("div", { class: "row" },
+ el("button", { class: "btn small", text: "Check now", onclick: () => { action("check_nuworks"); toast("Checking NUworks in the background…"); } }),
+ el("button", { class: "btn small", text: "Log in", onclick: () => action("login_nuworks") }))),
+ el("div", { class: "row between" }, el("span", { text: "Claude Code" }), el("button", { class: "btn small", text: "Log in", onclick: () => action("login_claude") })),
+ el("div", { class: "row between", testid: "gmail-login" }, el("div", {}, el("span", { text: "Gmail (optional)" }),
+ el("div", { class: "small muted", text: d.gmail.ready ? (d.gmail.days_left != null ? `read-only; ${Math.max(0, d.gmail.days_left)} days left` : "read-only") :
+ "read-only, for the assistant's email codes and links" })),
+ el("button", { class: "btn small", text: d.gmail.ready ? "Log in again" : "Log in", testid: "btn-login-gmail", onclick: () => action("login_gmail") })),
+ d.gmail.ready ? null : el("p", { class: "small muted" }, "Gmail first needs its API turned on in your Google Cloud project: ",
+ linkOut("https://console.cloud.google.com/apis/library/gmail.googleapis.com", "Gmail API"), ". Use the inbox of the assistant's account email (Assistant tab)."),
+ el("p", { class: "small muted", text: "Google logins last 7 days (Testing-mode apps); NUauto warns you a day before. Log in to Google again renews Gmail's too." }));
+ }
+ // the weekly limit, the resume NUworks' Apply popup picks, and your sheet
+ function applyingCard() {
+ const s = d.settings;
+ const label = el("input", { type: "text", value: d.resume_label, "aria-label": "Resume label", testid: "set-resume-label" });
+ const week = el("input", { type: "date", value: s.week_start || "", "aria-label": "Week start", testid: "set-week-start" });
+ const cap = el("input", { type: "number", min: "1", max: String(d.max_per_week_ceiling), step: "1", value: String(d.max_per_week),
+ "aria-label": "Applications per week", testid: "set-max-per-week" });
+ return el("div", { class: "card stack", testid: "applying-settings" }, el("h2", { text: "Applying on NUworks" }),
+ el("label", { class: "field" }, "Applications per week", el("span", { class: "muted small", text: `The most NUauto will submit in one week (1 to ${d.max_per_week_ceiling}). It stops at this number; Approved jobs wait for the next week.` }), cap),
+ el("label", { class: "field" }, "Week starts on", el("span", { class: "muted small", text: "Your weekly count starts over every 7 days from this date. Empty: it counts the last 7 days." }), week),
+ el("label", { class: "field" }, "Resume NUauto picks", el("span", { class: "muted small", text: "Its name exactly as in the Resume dropdown of NUworks' Apply popup (Setup > NUworks can read the list for you)." }), label),
+ s.sheet_id ? el("p", { class: "small", testid: "set-sheet" }, el("b", { text: "Your sheet: " }),
+ linkOut(`https://docs.google.com/spreadsheets/d/${encodeURIComponent(s.sheet_id)}`, "open it in Google Sheets"),
+ el("span", { class: "muted", text: " · to use another one: Setup > Your Google Sheet." })) : null,
+ el("div", {}, el("button", { class: "btn primary", text: "Save", testid: "settings-save", onclick: async () => {
+ try { d = await api.post("/api/settings", { resume_label: label.value, week_start: week.value, max_per_week: cap.value }); toast("Saved."); pollState(); draw(); } catch (e) { fail(e); } } })));
+ }
+ // The laptop: which homelab (connection, setup), then NUauto there (its page, its assistant), each its own card. The
+ // homelab's own page: the devices paired with it, and its assistant's queue.
+ function homelabCards() {
const ha = d.homelab_assistant;
const done = (r) => { if (r) { d = r; draw(); } else reload(); };
if (onHomelab()) {
- return el("div", { class: "card stack", testid: "homelab-settings" }, el("h2", { text: "This homelab" }),
- devicesPanel(), el("hr", { class: "sep" }), el("h3", { text: "Its assistant" }), homelabAssistantPanel(ha, done));
+ return [el("div", { class: "card stack", testid: "homelab-settings" }, el("h2", { text: "This homelab" }), devicesPanel()),
+ el("div", { class: "card stack" }, el("h2", { text: "Its assistant" }), homelabAssistantPanel(ha, done))];
}
- if (!(d.homelab && d.homelab.available)) return null; // the packaged app: always on its own
- return el("div", { class: "card stack", testid: "homelab-settings" }, el("h2", { text: "Homelab" }),
- homelabLinkPanel(d.homelab, () => reload()),
- ha.available ? [el("hr", { class: "sep" }), el("h3", { text: "NUauto on your homelab" }),
- el("div", { testid: "ha-settings" }, homelabAssistantPanel(ha, done))] : null);
+ return [el("div", { class: "card stack", testid: "homelab-settings" }, el("h2", { text: "Homelab" }), homelabLinkPanel(d.homelab, () => reload())),
+ ha.available ? el("div", { class: "card stack", testid: "ha-settings" }, el("h2", { text: "NUauto on your homelab" }), homelabAssistantPanel(ha, done)) : null];
}
+ // (the homelab's page) the devices paired with it: each one's cookie is its key (the homelab keeps only its hash)
function devicesPanel() {
const name = el("input", { type: "text", placeholder: "e.g. phone", maxlength: "40", "aria-label": "Name of the device", testid: "pair-name" });
return el("div", { class: "stack", testid: "devices" }, el("h3", { text: "Paired devices" }),
@@ -2011,7 +2082,7 @@ screens.settings = async (view) => {
el("span", { class: "muted small", text: help }),
here ? null : pdfDrop(`file-${kind}-drop`, d.files[kind] ? "Drop a new PDF here, or" : "Drop the PDF here, or", give(kind, done)));
return el("div", { class: "card stack", testid: "files-settings" }, el("h2", { text: "Your files" }),
- el("p", { class: "small muted", text: here ? "Copies from your laptop. Give NUauto there a new one (Settings > Your files): it copies it here at once." :
+ el("p", { class: "small muted", text: here ? "Copies from your laptop. Give NUauto there a new one (Settings > Applying > Your files): it copies it here at once." :
"NUauto keeps its own copy of each (in its private folder, readable only by you; your homelab, if you use one, gets a copy at once). To change one, drop the new PDF here." }),
part("resume", "Resume", "The PDF you use on NUworks: Claude compares each posting with it, and the assistant uploads it on company sites.", "Resume saved."),
part("transcript", "Transcript (optional)", "Your unofficial transcript: the assistant uploads it only where an application asks for one.", "Transcript saved."));
@@ -2046,15 +2117,17 @@ screens.settings = async (view) => {
} catch (e) { fail(e); }
} });
const here = onHomelab(); // the homelab's page: the folders and the export file are the laptop's
+ const pmOn = !here && d.password_manager && d.password_manager.on; // with Bitwarden on, its Add button is the way in
return el("div", { class: "card stack", testid: "assistant-settings" }, el("h2", { text: "Company-site assistant" }),
- el("label", { class: "field" }, "Account email", el("span", { class: "muted small", text: "For the accounts it makes on job sites (Workday and others). Each site gets its own random password; the assistant never sees it." }), email),
+ el("label", { class: "field" }, "Email for job-site accounts", el("span", { class: "muted small", text: "When a company's site needs an account (Workday and others), the assistant makes one with this email. Each site gets its own random password; the assistant never sees it." }), email),
el("label", { class: "field" }, "Effort", el("span", { class: "muted small", text: "How hard it thinks (Claude Code's effort). Nothing is submitted without your review either way." }), effort),
here ? el("p", { class: "small muted", text: "The folders it may read are on your laptop: set them in NUauto there (the homelab gets copies)." }) : [
- el("label", { class: "field" }, "Folders it may read", el("span", { class: "muted small", text: "Your projects and writeups, one per line: for longer answers. Keys, tokens, .env files and NUauto's own logins are never read." }), folders),
- el("label", { class: "field" }, "Your writeups (optional)", el("span", { class: "muted small", text: "Folders of your own project or security writeups, one per line: the first place it looks for essay material (and, with its assistant on the homelab, copied there)." }), writeups)],
- el("label", { class: "field" }, "Assistants at once", el("span", { class: "muted small", text: "How many company-site applications may run side by side, each with its own browser: on each machine, 1 to 3." }), slots),
+ el("label", { class: "field" }, "Folders it may read", el("span", { class: "muted small", text: "Your projects, one per line: it reads them for longer answers. Keys, tokens, .env files and NUauto's own logins are never read." }), folders),
+ el("label", { class: "field" }, "Writeups (optional)", el("span", { class: "muted small", text: "Folders of your own project or security writeups, one per line: where it looks first for material for longer answers (copied to your homelab if its assistant runs there)." }), writeups)],
+ el("label", { class: "field" }, "Assistants at once", el("span", { class: "muted small", text: "How many company-site applications may run side by side, each in its own browser (1 to 3, on each computer)." }), slots),
el("div", { class: "row between" }, el("span", { class: "small muted", testid: "logins-count",
- text: L.sites ? `Logins it made: ${plural(L.sites, "site")}${L.new ? `, ${L.new} not exported yet` : ", all exported"}` : "No logins made yet." }), here ? null : exportBtn),
+ text: !L.sites ? "No logins made yet." : pmOn ? `Logins it made: ${plural(L.sites, "site")}. Bitwarden is on: they go into your vault from Password manager, below.` :
+ `Logins it made: ${plural(L.sites, "site")}${L.new ? `, ${L.new} not exported yet` : ", all exported"}` }), here || pmOn ? null : exportBtn),
el("div", {}, el("button", { class: "btn primary", text: "Save", testid: "assistant-save", onclick: async () => {
try {
const both = { accounts_email: email.value, assist_effort: effort.value, assist_slots: Number(slots.value) };
@@ -2065,7 +2138,17 @@ screens.settings = async (view) => {
} })));
}
await reload();
- return { health: draw };
+ let seenTask = null;
+ return {
+ health: () => drawHealth(),
+ // Jobs > Read the list from NUworks is a task: once it ends, the term list (or why not) comes with fresh steps
+ task: (t) => {
+ if (!t || t.kind !== "onboard_terms" || t.state === "running" || t.id === seenTask) return;
+ seenTask = t.id;
+ setup = null;
+ if (tab === "jobs") draw();
+ },
+ };
};
// ---- Optional parts, the same in Settings and in setup: your password manager, and the assistant on a homelab.
@@ -2135,7 +2218,7 @@ function homelabAssistantPanel(ha, done) {
if (here) {
return el("div", { class: "stack", testid: "ha-panel" }, ha.on ?
el("p", { class: "small muted", text: "Company-site applications run here in a virtual browser; you answer each session on its screen (Apply > Assistants). Turning the assistant on or off is done in NUauto on your laptop." }) :
- el("p", { class: "small muted", text: "The assistant is off on this homelab: turn it on in NUauto on your laptop (Settings > NUauto on your homelab)." }), queue);
+ el("p", { class: "small muted", text: "The assistant is off on this homelab: turn it on in NUauto on your laptop (Settings > Homelab > NUauto on your homelab)." }), queue);
}
return el("div", { class: "stack", testid: "ha-panel" },
el("p", { class: "small muted", text: "Optional, with a homelab. Its page is this same NUauto, on the homelab: open it from your phone or another computer (over Tailscale; each browser is paired once) to review, apply and answer the assistant while this computer is off. NUauto here works the same either way." }),
@@ -2147,7 +2230,7 @@ function homelabAssistantPanel(ha, done) {
ha.app ? el("div", { class: "row" },
el("button", { class: "btn small", text: "Pair this computer", testid: "ha-pair", onclick: pairThisComputer }),
ha.review_url ? el("button", { class: "btn small ghost", text: "Open it", testid: "ha-open", onclick: () => openExternal(`${ha.review_url}/`) }) : null,
- el("span", { class: "small muted", text: "A phone: on the homelab, nuauto review pair --discord (or Settings > Paired devices on its page)." })) : null,
+ el("span", { class: "small muted", text: "A phone: on the homelab, nuauto review pair --discord (or Settings > Homelab > Paired devices on its page)." })) : null,
ha.app ? linkRow(done) : null,
el("hr", { class: "sep" }),
el("div", { class: "row between" }, el("div", { class: "row" }, el("b", { text: "Its assistant" }),
@@ -2183,6 +2266,7 @@ function homelabLinkPanel(h, done) {
"aria-label": "Its ssh name", testid: "hl-ssh" });
const folder = el("input", { type: "text", value: h.dir || h.default_dir, "aria-label": "Folder there", testid: "hl-folder" });
const out = el("div", { testid: "hl-result" });
+ const note = el("div"); // the restart note: outside the folded parts, so it shows wherever you pressed
const restartNote = () => el("div", { class: "note warn", testid: "hl-restart" }, el("b", { text: "Saved. " }),
"Quit NUauto and open it again to switch. ",
el("button", { class: "btn small", text: "Quit NUauto", onclick: async () => { await api.post("/api/quit", {}).catch(() => {});
@@ -2205,9 +2289,14 @@ function homelabLinkPanel(h, done) {
el("div", { class: "row" }, el("button", { class: "btn primary", testid: "hl-use", disabled: !f.tailscale || !f.hostname,
text: h.on && h.hostname === f.hostname ? "Save again" : "Use this homelab", onclick: async () => {
const r = await post({ action: "save", ssh: ssh.value, folder: folder.value, hostname: f.hostname, listen: f.tailscale });
- if (r) fill(out, restartNote());
+ if (r) { fill(out); fill(note, restartNote()); }
} }), el("span", { class: "small muted", text: "Then open NUauto again, and Set it up (each step asks first)." })));
}
+ const connect = [
+ el("div", { class: "row" }, el("label", { class: "field grow" }, "Its ssh name", el("span", { class: "muted small", text: "What you type after ssh (a key, no password)." }), ssh),
+ el("label", { class: "field grow" }, "NUauto's folder there", el("span", { class: "muted small", text: "Under its home folder." }), folder)),
+ el("div", {}, el("button", { class: "btn", text: "Check", testid: "hl-check", onclick: check })),
+ out];
return el("div", { class: "stack", testid: "hl-panel" },
el("p", { class: "small muted", text: "Optional. An always-on Linux computer you reach with ssh over Tailscale: it checks for new jobs twice a day and sends your Discord messages, so this computer can be off, and NUauto's page can run there for your phone. Without one, everything runs on this computer, the same way." }),
h.on ? el("div", { class: "row between" }, el("div", { class: "row" }, el("b", { text: "Using" }),
@@ -2218,13 +2307,12 @@ function homelabLinkPanel(h, done) {
el("button", { class: "btn small", text: "Log in to Claude there", testid: "hl-claude", onclick: async () => { const r = await post({ action: "claude" }); if (r) terminalResult(r); } }),
el("button", { class: "btn small danger", text: "Stop using it", testid: "hl-off", onclick: () => confirmBox("Stop using the homelab?",
"Everything runs on this computer again (after a restart of NUauto): its updates, Discord messages and NUauto's page there stop being used from here. Nothing on the homelab is deleted.",
- "Stop using it", async () => { const r = await post({ action: "off" }); if (r) fill(out, restartNote()); }, { danger: true }) }))) :
+ "Stop using it", async () => { const r = await post({ action: "off" }); if (r) fill(note, restartNote()); }, { danger: true }) }))) :
h.saved ? restartNote() : null,
- h.on ? deploysPanel(h, post) : null,
- el("div", { class: "row" }, el("label", { class: "field grow" }, "Its ssh name", el("span", { class: "muted small", text: "What you type after ssh (a key, no password)." }), ssh),
- el("label", { class: "field grow" }, "NUauto's folder there", el("span", { class: "muted small", text: "Under its home folder." }), folder)),
- el("div", {}, el("button", { class: "btn", text: "Check", testid: "hl-check", onclick: check })),
- out);
+ note,
+ // in use: which one (its ssh name, folder) and the advanced parts fold away; not yet: the fields come first
+ h.on ? el("details", { testid: "hl-change" }, el("summary", { text: "Use another homelab: its ssh name and folder" }), el("div", { class: "stack" }, ...connect)) : connect,
+ h.on ? el("details", { testid: "hl-advanced" }, el("summary", { text: "Advanced: where its code comes from, Mark-done links" }), deploysPanel(h, post)) : null);
}
// The homelab in use: where its code comes from (GitHub's main by itself, or this computer at each push:
diff --git a/src/nuauto/gui_static/setup.js b/src/nuauto/gui_static/setup.js
index 87b70d0..43480a2 100644
--- a/src/nuauto/gui_static/setup.js
+++ b/src/nuauto/gui_static/setup.js
@@ -117,7 +117,7 @@
return [
s.data.preview ? el("div", { class: "note small", testid: "setup-resume-preview" }, el("b", { text: "What Claude reads: " }), s.data.preview + "…") : null,
s.data.file ? el("p", { class: "small", testid: "setup-resume-now" }, el("b", { text: "Using: " }), pdfLine(s.data.file)) : null,
- el("p", { text: "The PDF you use on NUworks. Claude compares each posting with it. NUauto keeps its own copy (and gives your homelab one): when you change your resume, drop the new one here or in Settings > Your files." }),
+ el("p", { text: "The PDF you use on NUworks. Claude compares each posting with it. NUauto keeps its own copy (and gives your homelab one): when you change your resume, drop the new one here or in Settings > Applying > Your files." }),
pdfDrop("setup-resume-drop", s.data.file ? "Drop a new PDF here, or" : "Drop your resume PDF here, or", give),
s.data.suggestions.length ? el("div", { class: "small" }, el("span", { class: "muted", text: "Found on this computer: " }),
...s.data.suggestions.map((p) => el("button", { class: "btn small ghost", text: p.split("/").pop(), title: p, testid: "setup-resume-found",
@@ -257,7 +257,7 @@
field("Roles that clearly don't", "Claude drops only these.", area("drop_roles")))),
el("div", { class: "row" }, field("Match needed", "% for jobs open to your year", input("threshold", { type: "number" })),
field("…for the year above yours", "% (two years up: dropped)", input("threshold_above", { type: "number" }))),
- el("details", { testid: "pref-kinds-box" }, el("summary", { text: `Kinds of work: ${(p.categories || []).map((c) => c.label).join(", ")}` }),
+ el("details", { testid: "pref-kinds-box" }, el("summary", { text: `Kinds of work, with their ranking bonuses and lower match bars: ${(p.categories || []).map((c) => c.label).join(", ")}` }),
el("p", { class: "small muted", text: "Claude sorts every job into one of these (Review and Apply can show one kind at a time). Change the list and the jobs are sorted again on the next update. A ranking bonus moves that kind up; a lower match bar lets more of it into your pool; always last keeps it in the pool but at the end." }),
el("div", { class: "table-scroll" }, el("table", { class: "grid" }, el("thead", {}, el("tr", {}, ...["Kind of work", "What counts (Claude reads this)", "Ranking bonus", "Lower match bar", "Always last", ""].map((h) => el("th", { text: h })))),
kindBody)),
@@ -288,7 +288,8 @@
el("h3", { text: "NUauto on your homelab" }), homelabAssistantPanel(s.data.assistant, () => redraw())] : null];
},
- extras(s, redraw) {
+ // Discord alone: the homelab's page can change it too (the update and app icon parts are the laptop's)
+ discord(s, redraw) {
const d = s.data;
const url = el("input", { type: "text", placeholder: "https://discord.com/api/webhooks/…", "aria-label": "Discord webhook", testid: "setup-discord-url" });
return [
@@ -299,6 +300,13 @@
el("button", { class: "btn small danger", text: "Remove", onclick: async () => { if (await post({ action: "discord_remove" }, "Removed.")) redraw(); } })) :
el("div", { class: "row" }, url, el("button", { class: "btn", text: "Save", testid: "setup-discord-save",
onclick: async () => { if (await post({ action: "discord_save", url: url.value }, "Discord set up.")) redraw(); } })),
+ ];
+ },
+
+ extras(s, redraw) {
+ const d = s.data;
+ return [
+ ...BODY.discord(s, redraw),
el("hr", { class: "sep" }),
el("h2", { text: "Automatic updates" }),
d.homelab ? el("p", { class: "small muted", text: "Your homelab checks for new jobs twice a day." }) :
@@ -316,6 +324,9 @@
},
};
+ // Settings shows some steps' bodies too (its Jobs and App tabs): the same form, saved the same way
+ window.NUautoSetupBody = (id, step, redraw) => BODY[id](step, redraw).filter(Boolean);
+
window.NUautoSetup = async function (view) {
let data = null;
let dirty = false; // you changed a field and haven't saved: background refreshes must not redraw over it
diff --git a/src/nuauto/health.py b/src/nuauto/health.py
index 603b193..e314882 100644
--- a/src/nuauto/health.py
+++ b/src/nuauto/health.py
@@ -319,7 +319,7 @@ def bitwarden():
line there and logged in, and how many logins NUauto made still wait to go into your vault. Never unlocks it (its
"unlocked" belongs to the terminal you unlocked it in, so locked is fine here)."""
if (config.LOCAL.get("password_manager") or "") != "bitwarden":
- return [check("bitwarden", "Bitwarden", OFF, "Not used (optional: Settings, Password manager).")]
+ return [check("bitwarden", "Bitwarden", OFF, "Not used (optional: Settings > Assistant > Password manager).")]
from nuauto import accounts
waiting = accounts.waiting()
exe = None if config.DEMO else (shutil.which("bw") or config.tool_path("bw")) # demo: never your real Bitwarden
diff --git a/src/nuauto/link.py b/src/nuauto/link.py
index c6c86ca..2837c90 100644
--- a/src/nuauto/link.py
+++ b/src/nuauto/link.py
@@ -150,7 +150,7 @@ def main(argv):
print("linked" if key() else "not linked")
elif argv == ["forget"]:
forget()
- print("Forgotten here (unpair it on the homelab's page too: Settings > This homelab).")
+ print("Forgotten here (unpair it on the homelab's page too: Settings > Homelab > This homelab).")
else:
sys.exit(__doc__)
diff --git a/src/nuauto/onboard.py b/src/nuauto/onboard.py
index 322c787..040fb13 100644
--- a/src/nuauto/onboard.py
+++ b/src/nuauto/onboard.py
@@ -254,7 +254,7 @@ def resume_preview(path):
# ---------------------------------------------------------------- your files: the resume and the transcript
-# What you give NUauto (Setup's resume step, Settings > Your files) is kept as its own copy in local/, mode 600, and
+# What you give NUauto (Setup's resume step, Settings > Applying > Your files) is kept as its own copy in local/, mode 600, and
# local_config points at it: replacing the PDF you had elsewhere changes nothing until you give NUauto the new one.
# The laptop then pushes the copies to the homelab at once (gui.files_post -> sync.push_documents).
@@ -858,7 +858,7 @@ def _ask(what):
def homelab_deploy(on, repo=None):
- """`python -m nuauto.onboard homelab-deploy on|off []` (Settings > Homelab > Deploys, run as a GUI task): the
+ """`python -m nuauto.onboard homelab-deploy on|off []` (Settings > Homelab > Advanced, run as a GUI task): the
homelab deploys GitHub's main by itself (deploy_from_git; docs/DEPLOY.md, TURN ON DEPLOY FROM GITHUB), or the laptop
pushes its code again. repo: another fork's address ("" = this project's)."""
from nuauto import sync
@@ -951,7 +951,7 @@ def homelab_setup():
from nuauto import assist
run("sudo apt install -y xvfb x11vnc novnc nodejs npm tmux && "
f"npx -y -p {q(assist.MCP_PACKAGE)} playwright install firefox", tty=True)
- print("\nDone. Check it: Settings > Health > Run all checks now (or `nuauto doctor`); then `nuauto update` runs "
+ print("\nDone. Check it: Settings > Status > Run all checks now (or `nuauto doctor`); then `nuauto update` runs "
"one update there.")
diff --git a/src/nuauto/review.py b/src/nuauto/review.py
index 61519f0..2812491 100644
--- a/src/nuauto/review.py
+++ b/src/nuauto/review.py
@@ -645,7 +645,7 @@ def main(argv):
sys.exit(sync.ssh(f"cd {shlex.quote(config.SERVER_DIR)} && .venv/bin/nuauto review "
+ " ".join(shlex.quote(a) for a in argv)))
if not config.REMOTE_APP:
- sys.exit("Turn on NUauto on your homelab first: the laptop's Settings > NUauto on your homelab (\"remote_app\" in "
+ sys.exit("Turn on NUauto on your homelab first: the laptop's Settings > Homelab > NUauto on your homelab (\"remote_app\" in "
"local_config.json; docs/REMOTE.md).")
if argv[:1] == ["pair"]:
rest = [a for a in argv[1:] if a != "--discord"]
diff --git a/src/nuauto/window.py b/src/nuauto/window.py
index 15d98cc..537d634 100644
--- a/src/nuauto/window.py
+++ b/src/nuauto/window.py
@@ -4,7 +4,7 @@
Linux window_gtk.py, run by the system's python3 when it has GTK + WebKitGTK (most GNOME desktops)
else Chrome / Chromium / Brave / Edge in app mode (--app: no tabs, no address bar), else your default browser
Closing NUauto's own window quits NUauto (a run in progress stops the way Ctrl+C stops it). In a browser window or
-tab it keeps running until Settings > Quit, or 30 minutes after the last page closed.
+tab it keeps running until Settings > App > Quit, or 30 minutes after the last page closed.
"""
import os
import shutil
diff --git a/tests/screens.py b/tests/screens.py
index ecb3d3c..d218dc2 100644
--- a/tests/screens.py
+++ b/tests/screens.py
@@ -3,7 +3,8 @@
# every screen in app.js (screens.), and the old #/company, #/other, #/sheet (they land on Apply); #/assistant/
# needs a session
-ROUTES = ("home", "review", "apply", "company", "other", "sheet", "insights", "answers", "settings", "logs", "setup")
+ROUTES = ("home", "review", "apply", "company", "other", "sheet", "insights", "answers", "settings", "settings/jobs",
+ "settings/applying", "settings/assistant", "settings/homelab", "settings/app", "logs", "setup")
NAV = ["home", "review", "apply", "insights", "answers", "settings"] # the menu: the same on both
diff --git a/tests/test_gui.py b/tests/test_gui.py
index a1ea71b..f97e082 100644
--- a/tests/test_gui.py
+++ b/tests/test_gui.py
@@ -1267,7 +1267,9 @@ def review_again(): # a new Review screen: its cards are fetched again, its lis
# Install it shows and Add / Import are greyed out), the status strip gets its chip, Stop using it hides it again.
# No homelab in demo: no Assistant on your homelab card.
local_config = os.path.join(STATE, "local", "local_config.json")
+ page.click("[data-testid=settings-tab-assistant]")
page.wait_for_selector("[data-testid=pm-settings]")
+ assert page.url.endswith("#/settings/assistant")
assert page.locator("[data-testid=ha-settings]").count() == 0
assert page.inner_text("[data-testid=pm-toggle]") == "Use Bitwarden"
assert page.locator("[data-testid=pm-install]").count() == 0 and page.locator("[data-testid=health-bitwarden]").count() == 0
@@ -1305,6 +1307,7 @@ def review_again(): # a new Review screen: its cards are fetched again, its lis
assert page.locator("[data-testid=pm-install]").count() == 0
assert json.load(open(local_config))["password_manager"] == ""
page.wait_for_selector("[data-testid=health-bitwarden]", state="detached", timeout=20000)
+ page.click("[data-testid=settings-tab-app]")
page.click("[data-testid=btn-quit]")
page.click("[data-testid=confirm-yes]")
page.wait_for_selector("[data-testid=gone]")
diff --git a/tests/test_homelab_app.py b/tests/test_homelab_app.py
index 7d653e6..bd85f30 100644
--- a/tests/test_homelab_app.py
+++ b/tests/test_homelab_app.py
@@ -184,13 +184,21 @@ def eventually(check, what, seconds=20):
page.wait_for_selector("[data-testid=as-approval]:has-text('Approved: it is submitting now')")
# a laptop-only button explains instead of acting
page.goto(f"{ORIGIN}/#/settings")
- page.wait_for_selector("[data-testid=devices]")
+ page.wait_for_selector("[data-testid=check-google]")
page.click("button:has-text('Log in again')")
page.wait_for_selector("[data-testid=laptop-only]:has-text('on your laptop')")
page.keyboard.press("Escape")
- assert page.locator("[data-testid=btn-quit]").count() == 0 and page.locator("[data-testid=hl-panel]").count() == 0
- # one card for the homelab (its paired devices and its assistant), no Bitwarden card (the laptop's)
- assert page.locator("[data-testid=homelab-settings]").count() == 1 and page.locator("[data-testid=pm-settings]").count() == 0
+ page.goto(f"{ORIGIN}/#/settings/app")
+ page.wait_for_selector("[data-testid=extras-settings] [data-testid=setup-discord-url], [data-testid=extras-settings] .chip")
+ assert page.locator("[data-testid=btn-quit]").count() == 0 and page.locator("[data-testid=setup-launcher]").count() == 0
+ page.goto(f"{ORIGIN}/#/settings/assistant")
+ page.wait_for_selector("[data-testid=assistant-settings]")
+ assert page.locator("[data-testid=pm-settings]").count() == 0
+ page.goto(f"{ORIGIN}/#/settings/homelab")
+ page.wait_for_selector("[data-testid=devices]")
+ assert page.locator("[data-testid=hl-panel]").count() == 0
+ # the Homelab tab: its paired devices (and its assistant, a card of its own); no Bitwarden card (the laptop's)
+ assert page.locator("[data-testid=homelab-settings]").count() == 1
assert page.locator("[data-testid=homelab-settings] [data-testid=devices]").count() == 1
# Apply > Assistants: one line for the assistants and the queue; each session opened from its own row
page.goto(f"{ORIGIN}/#/apply")
diff --git a/tests/test_link.py b/tests/test_link.py
index 761989c..948fc0c 100644
--- a/tests/test_link.py
+++ b/tests/test_link.py
@@ -297,7 +297,7 @@ def ended():
page.wait_for_selector("text=Last run on the homelab: done", timeout=120000)
assert "marked Applied" in page.inner_text("#view")
# Settings: linked; Unlink, then Link again
- page.goto(base + "/#/settings")
+ page.goto(base + "/#/settings/homelab")
page.wait_for_selector("[data-testid=ha-link-status]:has-text('linked')")
assert page.inner_text("[data-testid=ha-link-status]") == "linked"
page.click("[data-testid=ha-link-toggle]")
@@ -305,7 +305,7 @@ def ended():
page.goto(base + "/#/company")
page.wait_for_selector("[data-testid=section-nuworks-agent]")
assert page.locator("[data-testid$=-homelab-start]").count() == 0 # not linked: no Start there
- page.goto(base + "/#/settings")
+ page.goto(base + "/#/settings/homelab")
page.click("[data-testid=ha-link-toggle]")
page.wait_for_selector("[data-testid=ha-link-status]:text-is('linked')")
# every screen, as on the other two pages