From 60f067dd0de4ecff3daf5327811dcc30e12ded17 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 4 Mar 2026 12:11:23 -0800 Subject: [PATCH] fix: Settings widget crash, stale setup banners, Docker service controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings → Search: add-title (+) and Import buttons crashed with StreamlitAPIException when writing to _sp_titles_multi after it was already instantiated. Fix: pending-key pattern (_sp_titles_pending / _sp_locs_pending) applied before widget renders on next pass. - Home setup banners: fired for email/notion/keywords even when those features were already configured. Add 'done' condition callables (_email_configured, _notion_configured, _keywords_configured) to suppress banners automatically when config files are present. - Services tab start/stop buttons: docker CLI was unavailable inside the container so _docker_available was False and buttons never showed. Bind-mount host /usr/bin/docker (ro) + /var/run/docker.sock into the app container so it can control sibling containers via DooD pattern. --- app/Home.py | 41 ++++++++++++++++++++++++++++++++++++----- app/pages/2_Settings.py | 12 ++++++++++-- compose.yml | 2 ++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/app/Home.py b/app/Home.py index 45cda39..2e51e35 100644 --- a/app/Home.py +++ b/app/Home.py @@ -25,17 +25,45 @@ from scripts.task_runner import submit_task init_db(DEFAULT_DB) +def _email_configured() -> bool: + _e = Path(__file__).parent.parent / "config" / "email.yaml" + if not _e.exists(): + return False + import yaml as _yaml + _cfg = _yaml.safe_load(_e.read_text()) or {} + return bool(_cfg.get("username") or _cfg.get("user") or _cfg.get("imap_host")) + +def _notion_configured() -> bool: + _n = Path(__file__).parent.parent / "config" / "notion.yaml" + if not _n.exists(): + return False + import yaml as _yaml + _cfg = _yaml.safe_load(_n.read_text()) or {} + return bool(_cfg.get("token")) + +def _keywords_configured() -> bool: + _k = Path(__file__).parent.parent / "config" / "resume_keywords.yaml" + if not _k.exists(): + return False + import yaml as _yaml + _cfg = _yaml.safe_load(_k.read_text()) or {} + return bool(_cfg.get("keywords") or _cfg.get("required") or _cfg.get("preferred")) + _SETUP_BANNERS = [ {"key": "connect_cloud", "text": "Connect a cloud service for resume/cover letter storage", - "link_label": "Settings → Integrations"}, + "link_label": "Settings → Integrations", + "done": _notion_configured}, {"key": "setup_email", "text": "Set up email sync to catch recruiter outreach", - "link_label": "Settings → Email"}, + "link_label": "Settings → Email", + "done": _email_configured}, {"key": "setup_email_labels", "text": "Set up email label filters for auto-classification", - "link_label": "Settings → Email (label guide)"}, + "link_label": "Settings → Email (label guide)", + "done": _email_configured}, {"key": "tune_mission", "text": "Tune your mission preferences for better cover letters", "link_label": "Settings → My Profile"}, {"key": "configure_keywords", "text": "Configure keywords and blocklist for smarter search", - "link_label": "Settings → Search"}, + "link_label": "Settings → Search", + "done": _keywords_configured}, {"key": "upload_corpus", "text": "Upload your cover letter corpus for voice fine-tuning", "link_label": "Settings → Fine-Tune"}, {"key": "configure_linkedin", "text": "Configure LinkedIn Easy Apply automation", @@ -513,7 +541,10 @@ with st.expander("⚠️ Danger Zone", expanded=False): # ── Setup banners ───────────────────────────────────────────────────────────── if _profile and _profile.wizard_complete: _dismissed = set(_profile.dismissed_banners) - _pending_banners = [b for b in _SETUP_BANNERS if b["key"] not in _dismissed] + _pending_banners = [ + b for b in _SETUP_BANNERS + if b["key"] not in _dismissed and not b.get("done", lambda: False)() + ] if _pending_banners: st.divider() st.markdown("#### Finish setting up Peregrine") diff --git a/app/pages/2_Settings.py b/app/pages/2_Settings.py index 383918a..adc48dd 100644 --- a/app/pages/2_Settings.py +++ b/app/pages/2_Settings.py @@ -324,6 +324,14 @@ with tab_search: st.session_state["_sp_excludes"] = "\n".join(p.get("exclude_keywords", [])) st.session_state["_sp_hash"] = _sp_hash + # Apply any pending programmatic updates BEFORE widgets are instantiated. + # Streamlit forbids writing to a widget's key after it renders on the same pass; + # button handlers write to *_pending keys instead, consumed here on the next pass. + for _pend, _wkey in [("_sp_titles_pending", "_sp_titles_multi"), + ("_sp_locs_pending", "_sp_locations_multi")]: + if _pend in st.session_state: + st.session_state[_wkey] = st.session_state.pop(_pend) + # ── Titles ──────────────────────────────────────────────────────────────── _title_row, _suggest_btn_col = st.columns([4, 1]) with _title_row: @@ -355,7 +363,7 @@ with tab_search: st.session_state["_sp_title_options"] = _opts if _t not in _sel: _sel.append(_t) - st.session_state["_sp_titles_multi"] = _sel + st.session_state["_sp_titles_pending"] = _sel st.session_state["_sp_new_title"] = "" st.rerun() with st.expander("📋 Paste a list of titles"): @@ -371,7 +379,7 @@ with tab_search: if _t not in _sel: _sel.append(_t) st.session_state["_sp_title_options"] = _opts - st.session_state["_sp_titles_multi"] = _sel + st.session_state["_sp_titles_pending"] = _sel st.session_state["_sp_paste_titles"] = "" st.rerun() diff --git a/compose.yml b/compose.yml index 8f2fc9e..186dd97 100644 --- a/compose.yml +++ b/compose.yml @@ -16,6 +16,8 @@ services: - ./config:/app/config - ./data:/app/data - ${DOCS_DIR:-~/Documents/JobSearch}:/docs + - /var/run/docker.sock:/var/run/docker.sock + - /usr/bin/docker:/usr/bin/docker:ro environment: - STAGING_DB=/app/data/staging.db - DOCS_DIR=/docs