diff --git a/CHANGELOG.md b/CHANGELOG.md index f373bd93..996debb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,22 @@ experience may change before `1.0.0`. ### Changed +- Body Double is for anything you are doing, and a live session is the + agent's screen. The **Focus** card is gone from Body Double: it listed the + three most recent study topics with an "at capacity" chip above the picker + and the terminal, which read as a limit on what could be body-doubled (the + activity field was always free text). The three-topic rule is unchanged where + study threads start, and committed focus is managed with `studyloop focus`; + notes taken in Body Double are filed under the activity. While a session is + live the view's heading and big timer step aside: the session strip carries + the activity, the Pomodoro (time, Start/Pause/Resume, Break) and **End + session**, the console fills the rest of the window, and Capture folds to one + row beneath it — its Note and Park tabs open it, and ending the session gives + back the layout you had. The floating Park-a-thought button and Pomodoro + widget step aside during a live Body Double session (the `P` shortcut still + parks), because they covered the terminal's last line and the End button. + Measured at 1440×900 before the change, the terminal started 432px down the + page and ran below the window. - The `openspec/` tree is no longer listed in `.gitignore`. Seventy-eight tracked, load-bearing files lived under an ignored path, so every new spec or archive file was invisible to `git status` and skipped by `git add -A`. @@ -67,6 +83,38 @@ experience may change before `1.0.0`. ### Fixed +- The web header is one line of controls on the brand's centre line. + **Voice**, **Theme**, **Font** and **Size** each have a visible label, set + above the control and out of the layout, so every control is the same 32px + height and sits on one centre line; the voice picker matches the other + pickers instead of a smaller 12px style. The voice-engine badge ("System + voices", "Kokoro (server)") is a coloured dot and small text beside the Voice + label, yellow when something other than the host's Kokoro is speaking; it + never had a style, so it rendered as large bold text in the middle of the + header. At a 1024px tablet width with the semantic chip showing, the controls + wrap instead of running 75px past the window. +- CSS and JavaScript are revalidated before a browser reuses them + (`Cache-Control: no-cache`, answered with a 304 when unchanged), like the page + itself. They were sent with no Cache-Control, so after an update a browser + could pair the new page with a days-old stylesheet or script. +- `studyloop doctor` no longer gives advice that is wrong or leads nowhere: + - `project_aliases` is no longer called inert. Session search reads it + (`PROJECT_ALIASES.md`), and deleting it as the row advised would have cut + `--project` searches off from every aliased path. + - Grok Build's definition is checked against the repo-root `AGENTS.md` it + shares with Codex, instead of "No manifest entry for grok". + - The xTiles row says whether the wind-down skill is installed and which + harnesses register an `xtiles` MCP server; the offer can only appear in a + session connected to one. + - A stale `export_freshness` row lists every harness's own age, and says so + when kiro-cli's session store (`~/.kiro/sessions/cli/`) has moved on while + Kiro exports have not: kiro-cli 2.x keeps sessions there and the exporter + does not read it yet (#49). + - The Kokoro model-files row appears only for `tts.backend: kokoro`, the one + backend that reads them, and says they download on first use. + - "Obsidian export disabled" says it means the session-memory export + (`obsidian.export_enabled`), which is independent of `second_brain`, and + how to turn it on. - Web (ACP) Kiro sessions run StudyLoop's own `studyloop` agent. They started `kiro-cli acp` with no `--agent`, so each ran under whatever the learner's default Kiro agent was: Kiro's built-in (its own system prompt, the learner's diff --git a/docs/web-ui-guide.md b/docs/web-ui-guide.md index a1403f1e..433a2e6e 100644 --- a/docs/web-ui-guide.md +++ b/docs/web-ui-guide.md @@ -48,7 +48,8 @@ the move goes with the old one, and a session you rejoin carries none. ## Body Double Use Body Double when the difficult part is starting or staying alongside the -work rather than learning a new concept. +work rather than learning a new concept. It is for anything you are doing — +study, a report, a tax return — so there is no topic list to choose from. 1. Open **Body Double**. 2. Name the activity in concrete terms, such as “trace one decorator call”. @@ -58,15 +59,20 @@ work rather than learning a new concept. move names an indexed lesson, **Open the lesson** opens it in the Course Explorer panel beside the picker, so the session can start with it already open next to you. -3. Choose an agent and start the Pomodoro timer if a time box would help. -4. Start the body-double session. The first move stays with you: it sits on - the session strip beneath the activity name for the whole session, with - **Open the lesson** beside it when a lesson was named, so the blank page - never arrives without it. It is a proposal on screen — the companion never - says it, and nothing opens unless you press the button. -5. Use **Focus** for up to three active topics and **Park a thought** for anything - that can wait. -6. End the session when the work block is complete. +3. Choose an agent, and set the Pomodoro lengths if a time box would help. +4. Start the body-double session. The page becomes the agent's screen: one + strip at the top with the activity, the Pomodoro (**Start Pomodoro**, + **Pause**, **Resume**) and **End session**, and the agent's console filling + the rest of the window. The first move stays with you: it sits on the strip + beneath the activity name for the whole session, with **Open the lesson** + beside it when a lesson was named. It is a proposal on screen — the + companion never says it, and nothing opens unless you press the button. +5. **Capture** folds to one row under the console while the session runs. + **Note** opens the note composer (notes are filed under the activity) and + **Park** keeps a tangent for later without leaving the session; the `P` + shortcut parks from anywhere. +6. End the session when the work block is complete. The page returns to the + layout you had before it started. ![A Body Double workspace with timer and a Kiro mentor](images/studyloop-body-double.png) diff --git a/packages/studyloop/src/studyloop/doctor/agents.py b/packages/studyloop/src/studyloop/doctor/agents.py index 92886d00..d0d243fd 100644 --- a/packages/studyloop/src/studyloop/doctor/agents.py +++ b/packages/studyloop/src/studyloop/doctor/agents.py @@ -392,8 +392,21 @@ def check_agent_definitions() -> list[CheckResult]: results: list[CheckResult] = [] manifest_agents = manifest.get("agents", {}) + from studyloop.installers import _TOOL_LINKS + for tool in tools: tool_keys = [k for k in manifest_agents if k.startswith(f"{tool}/")] + if not tool_keys: + # A harness with no definition of its own is checked against the + # manifest entries of the files its installer links. Grok Build reads + # the repo-root AGENTS.md that ``codex/AGENTS.md`` tracks; looking only + # for a ``grok/`` key reported "No manifest entry" and left the file it + # actually reads unchecked. + tool_keys = [ + key + for spec in _TOOL_LINKS.get(tool, ()) + if (key := spec.source.removeprefix("agents/")) in manifest_agents + ] if not tool_keys: results.append( CheckResult( @@ -411,6 +424,7 @@ def check_agent_definitions() -> list[CheckResult]: is_primary = Path(key).name == primary_name check_name = f"agent_{tool}" if is_primary else f"agent_{tool}_{Path(key).stem}" label = tool if is_primary else f"{tool} {Path(key).stem}" + shared = "" if key.startswith(f"{tool}/") else f" (shares {key})" if not install_path.exists(): results.append( @@ -418,7 +432,7 @@ def check_agent_definitions() -> list[CheckResult]: "agents", check_name, "warn", - f"{tool} detected but agent definition not installed", + f"{tool} detected but agent definition not installed{shared}", "studyloop upgrade --component agents", fix_auto=True, ) @@ -433,7 +447,7 @@ def check_agent_definitions() -> list[CheckResult]: "agents", check_name, "pass", - f"{label} agent definition current", + f"{label} agent definition current{shared}", "", False, ) @@ -447,6 +461,7 @@ def check_agent_definitions() -> list[CheckResult]: ( f"{label} agent definition outdated" f" (local={local_hash[:8]}... expected={expected_hash[:8]}...)" + f"{shared}" ), "studyloop upgrade --component agents", fix_auto=True, diff --git a/packages/studyloop/src/studyloop/doctor/config.py b/packages/studyloop/src/studyloop/doctor/config.py index bfb986fd..54842009 100644 --- a/packages/studyloop/src/studyloop/doctor/config.py +++ b/packages/studyloop/src/studyloop/doctor/config.py @@ -176,38 +176,43 @@ def check_pandoc() -> list[CheckResult]: ] +def _obsidian_export_disabled(vault: object, memory_dir: str) -> list[CheckResult]: + """The one "off" row for both ways export can be off. + + "Obsidian export disabled" alone named no reason and no next step, and a + learner on ``second_brain.provider: xtiles`` read it as "off because Obsidian + is not my second brain". The two settings are independent: this export writes + session-memory notes; ``second_brain`` only chooses where study notes go. + """ + target = f"{Path(str(vault)).expanduser()}/{memory_dir}" if str(vault) else memory_dir + return [ + CheckResult( + "config", + "obsidian_export", + "info", + "Obsidian session-memory export disabled: obsidian.export_enabled is not true. " + "It is independent of second_brain, which only chooses where study notes go.", + f"To write session notes into {target}, set export_enabled: true under the " + "obsidian: section of config.yaml", + False, + ) + ] + + def check_obsidian_export() -> list[CheckResult]: """Check Obsidian export configuration and vault writability. If export is enabled, verify that the resolved vault_path/memory_dir is present (or at least that the vault exists). If export is disabled, - return an informational result. + return an informational result that says how to turn it on. """ settings = _load_settings() obsidian = getattr(settings, "obsidian", None) if obsidian is None: - return [ - CheckResult( - "config", - "obsidian_export", - "info", - "Obsidian export disabled", - "", - False, - ) - ] + return _obsidian_export_disabled(getattr(settings, "obsidian_base", ""), "AgentMemory") if not obsidian.export_enabled: - return [ - CheckResult( - "config", - "obsidian_export", - "info", - "Obsidian export disabled", - "", - False, - ) - ] + return _obsidian_export_disabled(obsidian.vault_path, obsidian.memory_dir) # Export is enabled — verify vault_path / memory_dir is accessible. vault_path = Path(obsidian.vault_path).expanduser() @@ -307,13 +312,31 @@ def check_second_brain() -> list[CheckResult]: return [] if config.provider == "xtiles": + # "prompts and an opt-in assistant skill" read as "the skill still needs + # installing". It is installed for every harness by `studyloop install + # agents`; the opt-in is the learner's yes at wind-down. What decides + # whether the offer can ever appear is an MCP server named `xtiles` in + # the session, so the row says where one is registered. + from studyloop import installers + + skill = ( + "The wind-down skill is installed" + if installers.XTILES_SKILL_HUB.exists() + else "The wind-down skill is not installed (studyloop install agents installs it)" + ) + servers = installers.xtiles_mcp_harnesses() + where = ( + f"an xtiles MCP server is registered in: {', '.join(servers)}" + if servers + else "no harness here registers an xtiles MCP server, so the offer cannot appear yet" + ) return [ CheckResult( "config", "second_brain_provider", "info", - "Second brain: xTiles (no programmatic backend; prompts and an " - "opt-in assistant skill)", + f"Second brain: xTiles (no programmatic backend). {skill}; it offers to " + f"write only in a session with an xtiles MCP server connected, and {where}.", "See docs/second-brain.md for the xTiles setup and the three prompts.", False, ) diff --git a/packages/studyloop/src/studyloop/doctor/exporter.py b/packages/studyloop/src/studyloop/doctor/exporter.py index 7adfc0a0..66e8dfab 100644 --- a/packages/studyloop/src/studyloop/doctor/exporter.py +++ b/packages/studyloop/src/studyloop/doctor/exporter.py @@ -192,7 +192,11 @@ def newest_message_at(db_path: Path) -> datetime | None: conn.close() if not row or not row[0]: return None - text = str(row[0]).replace("Z", "+00:00") + return _parse_stamp(row[0]) + + +def _parse_stamp(raw: object) -> datetime | None: + text = str(raw).replace("Z", "+00:00") try: stamp = datetime.fromisoformat(text) except ValueError: @@ -200,12 +204,59 @@ def newest_message_at(db_path: Path) -> datetime | None: return stamp if stamp.tzinfo else stamp.replace(tzinfo=UTC) +#: Where kiro-cli 2.x keeps its sessions: one ``.jsonl`` plus ``.json`` +#: per session. The Kiro exporter still reads only data.sqlite3's conversation +#: tables, which kiro-cli stopped updating on 2026-09-05 on the machine that +#: reported the gap (StudyLoop #49). Until the exporter reads this store, the +#: directory changing after the newest ``kiro_cli`` export means Kiro sessions are +#: missing from the database. Retire this probe when #49 lands. +KIRO_SESSIONS_DIR = Path.home() / ".kiro" / "sessions" / "cli" + + +def newest_message_by_source(db_path: Path) -> dict[str, datetime]: + """Newest exported message per ``sessions.source``; ``{}`` when unreadable.""" + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + except sqlite3.Error: + return {} + try: + rows = conn.execute( + "SELECT s.source, MAX(m.timestamp) FROM messages m " + "JOIN sessions s ON s.id = m.session_id GROUP BY s.source" + ).fetchall() + except sqlite3.Error: + return {} + finally: + conn.close() + newest: dict[str, datetime] = {} + for source, raw in rows: + stamp = _parse_stamp(raw) if raw else None + if source and stamp: + newest[str(source)] = stamp + return newest + + +def _age(hours: float) -> str: + if hours < 1: + return "under 1 h" + return f"{hours:.0f} h" if hours < 48 else f"{hours / 24:.0f} d" + + def check_export_freshness( db_path: Path | None = None, *, now: datetime | None = None, max_age_hours: float = EXPORT_FRESHNESS_HOURS, + kiro_sessions_dir: Path | None = None, ) -> CheckResult: + """How long ago each harness last had a message exported. + + The newest message across every harness hid a 22-day Kiro gap behind a 48 h + Codex age (reported 2026-09-27), so a warning lists every harness's own age. + Kiro gets one more probe: kiro-cli's session store changing well after the + newest ``kiro_cli`` export means sessions the exporter cannot read (#49). That + warns even when another harness is fresh, because the fresh one hides it. + """ db_path = db_path or _db_path() newest = newest_message_at(db_path) if db_path.exists() else None if newest is None: @@ -217,15 +268,52 @@ def check_export_freshness( fix_hint="studyloop doctor --fix (installs the export hooks), then close one session", fix_auto=True, ) - age_hours = ((now or datetime.now(UTC)) - newest).total_seconds() / 3600 + now = now or datetime.now(UTC) + by_source = newest_message_by_source(db_path) + listing = ", ".join( + f"{source} {_age((now - stamp).total_seconds() / 3600)}" + for source, stamp in sorted(by_source.items(), key=lambda item: item[1], reverse=True) + ) + + store = kiro_sessions_dir or KIRO_SESSIONS_DIR + kiro_newest = by_source.get("kiro_cli") + try: + store_changed = datetime.fromtimestamp(store.stat().st_mtime, tz=UTC) + except OSError: + store_changed = None + if ( + kiro_newest is not None + and store_changed is not None + and (store_changed - kiro_newest).total_seconds() / 3600 > max_age_hours + ): + kiro_age = _age((now - kiro_newest).total_seconds() / 3600) + changed_age = _age((now - store_changed).total_seconds() / 3600) + return CheckResult( + category="harness", + name="export_freshness", + status="warn", + message=( + f"kiro_cli's newest export is {kiro_age} old, but kiro-cli wrote to {store} " + f"{changed_age} ago: kiro-cli keeps its sessions there now and the exporter " + f"does not read that store yet, so newer Kiro sessions are not in the " + f"database. By harness: {listing}" + ), + fix_hint="Nothing to repair locally; the exporter has to learn this store " + "(StudyLoop #49). The session files stay on disk for a later backfill.", + fix_auto=False, + ) + + age_hours = (now - newest).total_seconds() / 3600 if age_hours > max_age_hours: + by_harness = f"; by harness: {listing}" if listing else "" return CheckResult( category="harness", name="export_freshness", status="warn", message=( - f"newest exported message is {age_hours:.0f} h old (limit {max_age_hours:.0f} h); " - f"if you have used a harness since, read {installers.EXPORT_HOOK_LOG}" + f"newest exported message is {age_hours:.0f} h old (limit {max_age_hours:.0f} h)" + f"{by_harness}; if you have used a harness since, read " + f"{installers.EXPORT_HOOK_LOG}" ), fix_hint="", fix_auto=False, @@ -242,10 +330,12 @@ def check_export_freshness( __all__ = [ "EXPORT_FRESHNESS_HOURS", + "KIRO_SESSIONS_DIR", "check_export_freshness", "check_exporter_schema", "database_user_version", "exporter_schema_version", "newest_message_at", + "newest_message_by_source", "pinned_exporter_path", ] diff --git a/packages/studyloop/src/studyloop/doctor/voice.py b/packages/studyloop/src/studyloop/doctor/voice.py index 08e6ef1c..3c38ba2b 100644 --- a/packages/studyloop/src/studyloop/doctor/voice.py +++ b/packages/studyloop/src/studyloop/doctor/voice.py @@ -67,28 +67,35 @@ def check_voice_readiness() -> list[CheckResult]: ) ) - if _KOKORO_MODEL.exists() and _KOKORO_VOICES.exists(): - results.append( - CheckResult( - "voice", - "kokoro_models", - "pass", - "Kokoro model and voices are available", - "", - False, + # Only study-speak's local ``kokoro`` backend reads these files, and it + # downloads them itself on first use (agent_session_tools.speak). For any + # other backend the row asked a learner to pre-download ~354 MB that nothing + # on their machine would ever read, so it is not reported at all. + if backend == "kokoro": + if _KOKORO_MODEL.exists() and _KOKORO_VOICES.exists(): + results.append( + CheckResult( + "voice", + "kokoro_models", + "pass", + "Kokoro model and voices are available", + "", + False, + ) ) - ) - else: - results.append( - CheckResult( - "voice", - "kokoro_models", - "info", - "Kokoro model files are not pre-warmed", - "See docs/voice-output.md for the model download command", - False, + else: + results.append( + CheckResult( + "voice", + "kokoro_models", + "info", + "Kokoro model files (about 354 MB) are not downloaded yet; " + "study-speak fetches them on first use", + "Nothing to do. To fetch them now instead of on first use, see " + "docs/voice-output.md", + False, + ) ) - ) afplay = shutil.which("afplay") results.append( diff --git a/packages/studyloop/src/studyloop/installers.py b/packages/studyloop/src/studyloop/installers.py index 09566bad..33996c2c 100644 --- a/packages/studyloop/src/studyloop/installers.py +++ b/packages/studyloop/src/studyloop/installers.py @@ -991,6 +991,34 @@ def unregister_mcp_servers(tools: list[str]) -> dict[str, int]: return changed +def xtiles_mcp_harnesses() -> list[str]: + """Harnesses whose MCP config registers a server named ``xtiles``, sorted. + + Read-only, for the doctor's xTiles row. The wind-down skill offers only in a + session where an ``xtiles`` MCP server is connected, so where one is + registered decides where the offer can ever appear. Reads the same files + :func:`_mcp_config_path` names; Grok Build is not read (its registration goes + through its own CLI). A missing or unreadable config counts as "not + registered there", never as an error. + """ + import json + import tomllib + + sections = {"claude": "mcpServers", "codex": "mcp_servers", "kiro": "mcpServers"} + sections["opencode"] = "mcp" + found: list[str] = [] + for tool, section in sections.items(): + try: + text = _mcp_config_path(tool).read_text(encoding="utf-8") + data = tomllib.loads(text) if tool == "codex" else json.loads(text) + except (OSError, ValueError): + continue + servers = data.get(section) if isinstance(data, dict) else None + if isinstance(servers, dict) and "xtiles" in servers: + found.append(tool) + return sorted(found) + + def mcp_registration_status(tools: list[str] | None = None) -> dict[str, bool]: """Report registration state without modifying any harness configuration.""" import json diff --git a/packages/studyloop/src/studyloop/settings.py b/packages/studyloop/src/studyloop/settings.py index 19a38bcb..13127ff5 100644 --- a/packages/studyloop/src/studyloop/settings.py +++ b/packages/studyloop/src/studyloop/settings.py @@ -611,6 +611,12 @@ def replace_config(_current: dict[str, Any]) -> dict[str, Any]: "semantic_search", "excluded_dirs", "endpoints", # legacy sync format, agent_session_tools.get_endpoints() + # agent_session_tools.query_utils.build_project_filter: the explicit + # alias groups that let session search find one project's history + # under old paths, usernames and worktrees (PROJECT_ALIASES.md). Read + # with load_config().get(), not through DEFAULT_CONFIG, so the + # unknown-key check called it inert and told learners to delete it. + "project_aliases", } ) diff --git a/packages/studyloop/src/studyloop/web/app.py b/packages/studyloop/src/studyloop/web/app.py index 81b5949b..20835512 100644 --- a/packages/studyloop/src/studyloop/web/app.py +++ b/packages/studyloop/src/studyloop/web/app.py @@ -8,7 +8,7 @@ from contextlib import asynccontextmanager from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from fastapi import FastAPI, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse @@ -22,6 +22,23 @@ STATIC_DIR = Path(__file__).parent / "static" +class RevalidatedStaticFiles(StaticFiles): + """Static files the browser must revalidate before reusing. + + ``/`` is served ``no-store``, but without a Cache-Control of their own the + CSS and JS it loads were subject to heuristic freshness -- commonly a tenth + of the file's age -- so a stylesheet unchanged for weeks was reused for days + without asking. After an update the learner got the new page beside the old + CSS and JS. ``no-cache`` keeps the copy and asks first; an unchanged file + costs a 304 against the ETag Starlette already sends. + """ + + def file_response(self, *args: Any, **kwargs: Any) -> Response: + response = super().file_response(*args, **kwargs) + response.headers["Cache-Control"] = "no-cache" + return response + + @asynccontextmanager async def _lifespan(app: FastAPI) -> AsyncIterator[None]: """Prepare the database, then run the session-slot reaper for the app's life. @@ -353,7 +370,7 @@ async def session_page() -> RedirectResponse: return RedirectResponse(url="/#study-session") # Mount static files LAST (catch-all) - app.mount("/", StaticFiles(directory=str(STATIC_DIR)), name="static") + app.mount("/", RevalidatedStaticFiles(directory=str(STATIC_DIR)), name="static") # Security headers: wraps the FINISHED app object rather than being # registered via app.add_middleware(). Starlette always puts its own diff --git a/packages/studyloop/src/studyloop/web/static/components.js b/packages/studyloop/src/studyloop/web/static/components.js index 7db0d027..e7762621 100644 --- a/packages/studyloop/src/studyloop/web/static/components.js +++ b/packages/studyloop/src/studyloop/web/static/components.js @@ -3160,9 +3160,7 @@ function notesPanel() { * ==================================================================== */ function bodyDoubleSession() { return { - slots: [], slotsUsed: 0, maxActive: 3, atCapacity: false, parkingLotCount: 0, - focus: { topics: [], is_set: false, is_stale: false }, - focusCollapsed: false, captureCollapsed: false, captureTab: 'note', + captureCollapsed: false, captureTab: 'note', activity: '', firstMove: '', firstMoveLessonId: '', firstMoveLessonTitle: '', agent: '', transport: 'pty', energy: 5, agents: [], sessionActive: false, liveActivity: '', confirmingEnd: false, @@ -3186,7 +3184,7 @@ function bodyDoubleSession() { comparison would then work only by accident. */ _conflictEpoch: 0, /* True once init() has finished ALL its async work. The only honest ready - signal for tests: the conflict probe settles after the focus and options + signal for tests: the conflict probe settles after the options and notes loads, so nothing else marks the end of init(). */ _initDone: false, noteKind: 'note', noteTopic: '', noteTitle: '', noteBody: '', @@ -3251,9 +3249,18 @@ function bodyDoubleSession() { this.firstMoveLessonTitle = detail.firstMoveLessonTitle ? String(detail.firstMoveLessonTitle) : ''; }); - this.focusCollapsed = localStorage.getItem('bd.focus.collapsed') === 'true'; this.captureCollapsed = localStorage.getItem('bd.capture.collapsed') === 'true'; - await this.refreshFocus(); + /* A live session is the agent's screen, so Capture folds when one starts + and the learner's own idle choice comes back when it ends. One watcher + rather than a line in each path, because a session goes live three + ways (start, reattach, adopt) and ends several more; a fold wired into + only some of them would leave the note form over the console in the + rest. Not persisted: this is the session's layout, not a preference. */ + this.$watch('sessionActive', (live) => { + this.captureCollapsed = live + ? true + : localStorage.getItem('bd.capture.collapsed') === 'true'; + }); try { const res = await fetch('/api/session/options'); if (res.ok) { @@ -3310,54 +3317,20 @@ function bodyDoubleSession() { this.clearFirstMove(); }, - async refreshFocus() { - try { - const res = await fetch('/api/body-double/focus'); - if (!res.ok) return; - const d = await res.json(); - this.slots = d.slots || []; - this.slotsUsed = d.slots_used ?? this.slots.length; - this.maxActive = d.max_active ?? 3; - this.atCapacity = !!d.at_capacity; - this.parkingLotCount = d.parking_lot_count || 0; - this.focus = d.focus || { topics: [], is_set: false, is_stale: false }; - /* Default the note topic to what the learner is actually doing. A note - filed against the wrong topic is worse than an untagged one. */ - if (!this.noteTopic) { - this.noteTopic = this.liveActivity || (this.slots[0] && this.slots[0].topic) || ''; - } - } catch { - Alpine.store('toast').show('Could not load focus — offline?'); - } + /* The note composer's topic choices: what the learner is working on. While + a session is live that is its activity; afterwards, the topic the last + note was filed under, so an unsaved draft keeps its topic. Never a list + of study topics — this surface is for anything (2026-09-28). */ + get noteTopicOptions() { + const topic = (this.liveActivity || this.noteTopic || '').trim(); + return topic ? [topic] : []; }, - toggleFocus() { - this.focusCollapsed = !this.focusCollapsed; - localStorage.setItem('bd.focus.collapsed', String(this.focusCollapsed)); - }, toggleCapture() { this.captureCollapsed = !this.captureCollapsed; localStorage.setItem('bd.capture.collapsed', String(this.captureCollapsed)); }, - pickTopic(slot) { this.activity = slot.topic; }, - - async dropTopic(topic) { await this._setFocus(this.focus.topics.filter((t) => t !== topic)); }, - async clearFocus() { await this._setFocus([]); }, - async _setFocus(topics) { - try { - const res = await fetch('/api/body-double/focus', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topics }), - }); - if (res.ok) await this.refreshFocus(); - else Alpine.store('toast').show('Could not update focus — try again'); - } catch { - Alpine.store('toast').show('Could not update focus — offline?'); - } - }, - applyTemplate() { this.noteBody = this.templates[this.noteKind] || ''; }, insertDiagram() { @@ -3439,7 +3412,6 @@ function bodyDoubleSession() { // discard. if (this.parkQuestion.trim() === question) this.parkQuestion = ''; if (this.parkNotes === notes) this.parkNotes = ''; - await this.refreshFocus(); window.dispatchEvent(new CustomEvent('parking:changed')); } catch { Alpine.store('toast').show('Could not park — offline?'); @@ -3478,10 +3450,8 @@ function bodyDoubleSession() { /* A start that succeeded proves nothing is blocking us any more. */ this.conflictSession = null; this.liveActivity = topic; - /* Re-point the note composer at the live activity. refreshFocus() ran at - init(), before any session existed, so its default fell back to the - first focus slot - filing notes against the wrong topic for the whole - session. A misfiled note is worse than an untagged one. */ + /* File notes under the live activity. A note filed against the wrong + topic is worse than an untagged one. */ this.noteTopic = topic; window.dispatchEvent(new CustomEvent('study-session-start', { detail: { diff --git a/packages/studyloop/src/studyloop/web/static/index.html b/packages/studyloop/src/studyloop/web/static/index.html index 102a7381..1a91fe66 100644 --- a/packages/studyloop/src/studyloop/web/static/index.html +++ b/packages/studyloop/src/studyloop/web/static/index.html @@ -134,17 +134,25 @@ - - - + +
+ + + + + +
-
-
+
+ +

Body Double

A focused workspace with timer and terminal. Just be here.

@@ -1670,7 +1687,7 @@

Body Double

reason this surface exists, and test_remaining_surface asserts #bd-timer-display matches the SERVED defaults, so it binds to the store rather than hardcoding 00:00 as the old markup did. --> -
+
00:00
@@ -1687,39 +1704,12 @@

Body Double

- -
-
- - - - - stale -
-
-
- Nothing in focus yet. Park a tangent or set a focus to fill these slots. -
- - at capacity - -
-
+ -
+
+ + Break + +
@@ -1867,7 +1867,10 @@

Body Double

- + +
-
+
+