diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bbabd7093..9161c58c1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -49,6 +49,15 @@ Möbius is meant to be self-hosted on a user-provisioned host — a managed plat Two invariants follow. (1) **Möbius never patches the kernel from inside the container** — it only *surfaces* "host reboot pending / kernel CVE outstanding" to the owner; the platform/OS applies it. (2) **The in-container agent cannot recreate its own container** (the swap would kill its own process), so the shape is *propose-in* (agent scans → bumps → tests → commits) / *dispose-out* (a host-driven `deploy-prod.sh`, or blue-green, does the rebuild+recreate). Detection is the agent's leverage on every tier: `pip-audit` + `npm audit` + an image scanner (Trivy / `docker scout`) over the built image → triage → bump → test → deploy (tier 1) or surface a reboot window (tiers 2/3). +**The in-product platform updater does not update host deployment files.** +Settings advances the served `/data/platform` clone inside the app volume; the +bundled self-hosted Caddy service instead reads `./Caddyfile` from the host +checkout, and image/dependency changes likewise need a host rebuild/recreate. +An incoming change to `Caddyfile`, `docker-compose.yml`, `Dockerfile`, or a +dependency manifest therefore carries a separate host action (and may require a +particular ordering) even when the live clone rebases cleanly. Never describe +Apply + server restart alone as activating those files. + **lodash is pinned to 4.18.1 via `overrides`.** `@openai/apps-sdk-ui` pulls lodash transitively — only through its `Slider` component, which the shell does not import. The 4.17.x line sat unfixed against several advisories for a long stretch; 4.18.x restored maintenance and patched them, so `frontend/package.json` `overrides` forces the transitive lodash to 4.18.1 (`npm audit` is clean). As defense-in-depth, `frontend/src/lib/__tests__/appsSdkLodash.test.js` also fails if the shell ever imports `Slider`, which keeps lodash tree-shaken out of the shipped bundle regardless of the pin. ## Self-update model — `upstream` / `main`, replay on update diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 89feced82..a76cd9818 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -37,6 +37,28 @@ model, lifecycle rules, and escape hatches. it collapses the boundary it is meant to protect. A deliberately trusted app must receive its own origin or become an explicit platform extension. +## Credentials and authority + +Opacity removes the shell's ambient **owner** authority; it does not make an +ordinary app powerless. In the current transport, the runtime receives a +refreshable app-scoped bearer and app code in that realm must be treated as able +to possess it. The bearer is narrower than the owner JWT: its app id, +installation nonce, owner epoch and installed permissions are rechecked by the +server. + +Credential placement and granted authority are separate decisions. A future +host-mediated request transport could keep the raw app bearer in the exact +parent while still letting the live frame invoke every server operation its +installed permissions allow. That would reduce theft and replay outside the +frame; it would not reduce the app's approved authority while the frame is +running. A generic request transport also need not become one bespoke wrapper +per API route — server permissions remain the authorization contract. + +This does not replace lifecycle-aware browser capabilities or the trust tiers +above. Origin-bound facilities such as cookies, service workers and durable +origin storage still require a host provider or a separate service origin; a +raw general shell-origin bridge would recreate the authority opacity removed. + ## Manifest contract Runtime capabilities live in the root `capabilities` object: @@ -224,11 +246,12 @@ Add primitives only after a real app needs them. Likely families are: - `device.midi`, `device.serial`, `device.bluetooth` - `display.fullscreen`, `display.wake_lock` -External HTTP remains an app-token authenticated server surface rather than a -host session. Its reviewable permission should describe destinations and +Today external HTTP remains an app-token-authenticated server surface rather +than a host session. Its reviewable permission should describe destinations and methods; wildcard access can remain possible through explicit owner approval. -Likewise, app storage and cross-app access are durable server capabilities, not -browser-session providers. +Moving credential possession into a generic host request transport would not +change that authorization model. Likewise, app storage and cross-app access are +durable server capabilities, not browser-session providers. ## Adding a capability diff --git a/SECURITY.md b/SECURITY.md index 91627c430..723e1abe9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,8 +28,11 @@ being external attackers reaching the public HTTPS endpoint. - **Mini-app isolation and tokens:** shell-mounted app frames omit `allow-same-origin`, giving them an opaque origin. They cannot read shell localStorage or the owner JWT. Each receives a refreshable app JWT bound to - the live app id, installation nonce and owner token epoch; server routes - enforce the app's exact permissions. + the live app id, installation nonce and owner token epoch; app code must be + treated as able to possess that narrower bearer, and server routes enforce + the app's exact installed permissions. Opacity protects ambient **owner** + authority — it is not a promise that ordinary app code never sees its own + scoped credential. - **Rate limiting:** 120 req/min global, 3-5/min on auth endpoints. Uses TCP peer address (not X-Forwarded-For). @@ -47,8 +50,11 @@ These are intentional design decisions appropriate for a single-owner app: outer PWA shell which hosts the existing opaque app-frame protocol. Until then, standalone launch must not be presented as isolated from owner storage. - **`null` CORS origin:** Required for sandboxed mini-app iframes to call - the API. Mitigated by scoped tokens — even if a mini-app reads the - iframe's token, it can only access storage/proxy/AI endpoints. + the API. Mitigated by scoped tokens — even if a mini-app reads or copies its + bearer, it can reach only routes authorized by that app's installed + permissions, live installation nonce and owner token epoch. Keep this stated + in terms of the principal rather than an endpoint list: app-authorized routes + evolve, and there is no synchronous `/api/ai` surface. - **`unsafe-inline` in style-src CSP:** Required for server-injected theme CSS. The owner controls the theme content. - **90-day service token:** Used by cron scripts. Stored at diff --git a/backend/app/app_activity.py b/backend/app/app_activity.py new file mode 100644 index 000000000..5e10bdd72 --- /dev/null +++ b/backend/app/app_activity.py @@ -0,0 +1,105 @@ +"""Durable per-app unread activity derived from app-attributed notifications.""" + +from sqlalchemy import update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app import models +from app.timeutil import now_naive_utc + + +def mark_from_notification( + db: Session, *, source_type: str, source_id: str | None, +) -> int | None: + """Mark a live app unread inside the caller's notification transaction. + + Returns the app id when a durable marker was written. The update-first, + savepoint-backed insert is race-safe when an existing app receives its first + two notifications concurrently, without committing the caller's transaction. + """ + if source_type != "app" or source_id is None: + return None + try: + app_id = int(source_id) + except (TypeError, ValueError): + return None + # SQLite INTEGER keys are signed 64-bit values. Reject values outside that + # bindable range before ``Session.get`` so malformed attribution cannot turn + # an otherwise valid notification into a persistence failure. + if app_id <= 0 or app_id > (2**63 - 1): + return None + app = db.get(models.App, app_id) + if app is None or app.deleted_at is not None: + return None + + now = now_naive_utc() + result = db.execute( + update(models.AppActivityState) + .where(models.AppActivityState.app_id == app_id) + .values( + activity_at=now, + activity_version=models.AppActivityState.activity_version + 1, + unseen=True, + ) + ) + if result.rowcount: + return app_id + + try: + with db.begin_nested(): + db.add(models.AppActivityState( + app_id=app_id, activity_at=now, unseen=True, + )) + db.flush() + except IntegrityError: + # Another first notification inserted the singleton row after our UPDATE. + # The savepoint kept the outer Notification insert intact; make this event + # the winning latest marker. + db.execute( + update(models.AppActivityState) + .where(models.AppActivityState.app_id == app_id) + .values( + activity_at=now, + activity_version=models.AppActivityState.activity_version + 1, + unseen=True, + ) + ) + return app_id + + +def mark_seen(db: Session, app_id: int, seen_through_version: int) -> None: + """Acknowledge only activity the opening shell actually observed. + + A newer notification can race the acknowledgement request. Bounding the + update by its observed monotonic version keeps that newer event unread instead of + letting a late acknowledgement erase it. + """ + db.execute( + update(models.AppActivityState) + .where( + models.AppActivityState.app_id == app_id, + models.AppActivityState.activity_version <= seen_through_version, + ) + .values(unseen=False) + ) + + +def annotate_apps(db: Session, apps: list[models.App]) -> list[models.App]: + """Attach the response-only ``has_unseen_activity`` flag to app rows.""" + ids = [app.id for app in apps] + unseen_by_id = {} + if ids: + unseen_by_id = { + row.app_id: row.activity_version + for row in db.query( + models.AppActivityState.app_id, + models.AppActivityState.activity_version, + ).filter( + models.AppActivityState.app_id.in_(ids), + models.AppActivityState.unseen.is_(True), + ).all() + } + for app in apps: + app.has_unseen_activity = app.id in unseen_by_id + app.unseen_activity_version = unseen_by_id.get(app.id) + return apps diff --git a/backend/app/app_compile_contract.py b/backend/app/app_compile_contract.py index bac1408ca..f42a38bd3 100644 --- a/backend/app/app_compile_contract.py +++ b/backend/app/app_compile_contract.py @@ -101,10 +101,17 @@ def runtime_library_aliases() -> tuple[tuple[str, Path], ...]: ``app_runtime_inject.js``. React then sees two dispatchers and every hook fails at first render. Package-root aliases apply to the root and its subpaths (for example ``react/jsx-runtime``), keeping each supported library singular. + + Three's documented ``three/addons/*`` export is backed by the physical + ``examples/jsm`` directory rather than an ``addons`` directory. Once the + package root is replaced with an absolute alias, esbuild no longer consults + Three's package exports for that subpath. Pin the public addons spelling to + its runtime-owned physical directory before adding the package roots. """ node_path = runtime_node_path() roots = sorted({_package_root(specifier) for specifier in BUNDLED_RUNTIME_LIBS}) - return tuple((root, node_path / root) for root in roots) + subpaths = (("three/addons", node_path / "three" / "examples" / "jsm"),) + return subpaths + tuple((root, node_path / root) for root in roots) NO_DEFAULT_EXPORT_ERROR = ( diff --git a/backend/app/models.py b/backend/app/models.py index f75e67b4f..08f2ff2bc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -521,6 +521,23 @@ class App(Base): ) +class AppActivityState(Base): + """Durable unread-activity marker for one installed app. + + This deliberately lives outside ``apps``: acknowledging a report must not + advance ``App.updated_at``, which is the shell's executable-bundle cache key. + Notifications remain the detailed history; the drawer only needs one compact + unread/read row per app. + """ + + __tablename__ = "app_activity_state" + + app_id = Column(Integer, ForeignKey("apps.id"), primary_key=True) + activity_at = Column(DateTime, nullable=False, default=lambda: now_naive_utc()) + activity_version = Column(Integer, nullable=False, default=1, server_default="1") + unseen = Column(Boolean, nullable=False, default=True, server_default=true()) + + class PushSubscription(Base): """Browser push subscription for Web Push delivery.""" diff --git a/backend/app/platform_update.py b/backend/app/platform_update.py index d52f4c497..60e456932 100644 --- a/backend/app/platform_update.py +++ b/backend/app/platform_update.py @@ -42,10 +42,13 @@ import asyncio import contextlib import fcntl +import logging import os +import stat import subprocess import sys -from dataclasses import dataclass, field +import tempfile +from dataclasses import dataclass, field, replace from enum import Enum from pathlib import Path from typing import Literal, TypedDict @@ -55,6 +58,8 @@ from app import app_git +log = logging.getLogger(__name__) + PLATFORM_REPO = Path("/data/platform") # The served backend — the import probe's cwd, so ``import app.main`` resolves # from the clone exactly as the uvicorn exec does. @@ -106,6 +111,14 @@ # in agent-edited code would otherwise wedge boot forever; a timeout-kill counts # as probe-fail -> roll back. _PROBE_TIMEOUT = 60 +# Hook installation only copies a handful of local files and updates one +# repo-local config value. A long run is a wedged filesystem/process, not work. +_HOOK_INSTALL_TIMEOUT = 15 +_HOOK_MAX_BYTES = 1_000_000 +_HOOK_SOURCES = ( + ("scripts/pre-commit.sh", "pre-commit"), + ("scripts/githooks/pre-push", "pre-push"), +) # Update-preview payload bounds. A whole-platform deploy can carry a huge diff; # the review sheet renders the file summary (always small) by default and the raw @@ -239,6 +252,10 @@ class ReconcileResult: target_sha: str | None conflict_paths: list[str] = field(default_factory=list) error: str | None = None + # Exact reviewed release/upstream commit captured while RECONCILE_LOCK is + # still held. Hook refresh reads every allowlisted blob from this immutable + # generation rather than trusting replayed local HEAD or a moving ref. + hook_source_sha: str | None = None def _scrubbed_git_env(repo: Path) -> dict: @@ -731,6 +748,166 @@ def _touched_frontend(repo: Path, before: str | None, after: str | None) -> bool ) +def _hook_git(repo: Path, *args: str) -> subprocess.CompletedProcess: + """Run one bounded, non-interactive Git plumbing command for hook refresh.""" + return subprocess.run( + ["git", "-C", str(repo), *args], + cwd=str(repo), + env=_scrubbed_git_env(repo), + capture_output=True, + timeout=_HOOK_INSTALL_TIMEOUT, + check=False, + ) + + +def _hook_command_error(proc: subprocess.CompletedProcess) -> str: + raw = proc.stderr or proc.stdout or f"exit {proc.returncode}".encode() + return os.fsdecode(raw).strip()[-500:] + + +def _stage_hook_file(hooks_dir: Path, data: bytes) -> Path: + fd, raw_path = tempfile.mkstemp(prefix=".mobius-hook-", dir=str(hooks_dir)) + path = Path(raw_path) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fchmod(handle.fileno(), 0o755) + os.fsync(handle.fileno()) + return path + except Exception: + path.unlink(missing_ok=True) + raise + + +def _read_hook_destination(path: Path) -> tuple[str, object] | None: + """Snapshot one destination without ever following a hook symlink.""" + try: + info = path.lstat() + except FileNotFoundError: + return None + if stat.S_ISLNK(info.st_mode): + return ("symlink", os.readlink(path)) + if not stat.S_ISREG(info.st_mode): + raise OSError(f"hook destination is not a regular file: {path.name}") + if info.st_size > _HOOK_MAX_BYTES: + raise OSError(f"existing hook is unexpectedly large: {path.name}") + return ("file", (path.read_bytes(), stat.S_IMODE(info.st_mode))) + + +def _restore_hook_destination(path: Path, previous: tuple[str, object] | None) -> None: + if previous is None: + path.unlink(missing_ok=True) + return + kind, value = previous + if kind == "file": + data, mode = value + staged = _stage_hook_file(path.parent, data) + os.chmod(staged, mode) + else: + staged = path.parent / f".mobius-hook-link-{os.getpid()}-{path.name}" + staged.unlink(missing_ok=True) + os.symlink(value, staged) + os.replace(staged, path) + + +def _refresh_git_hooks_impl(repo: Path, source_oid: str) -> str | None: + """Install allowlisted hooks from one pinned reviewed oid, without executing it.""" + # Preserve the rollout contract of older trees: no committed installer means + # this checkout predates managed hooks and boot should simply skip refresh. + enabled = _hook_git( + repo, "cat-file", "-e", f"{source_oid}:scripts/install-hooks.sh", + ) + if enabled.returncode != 0: + return None + + sources: list[tuple[str, bytes]] = [] + for source, destination in _HOOK_SOURCES: + blob = f"{source_oid}:{source}" + size_proc = _hook_git(repo, "cat-file", "-s", blob) + if size_proc.returncode != 0: + raise OSError(_hook_command_error(size_proc)) + try: + size = int(size_proc.stdout.strip()) + except (TypeError, ValueError) as exc: + raise OSError(f"could not size committed hook {source}") from exc + if size <= 0 or size > _HOOK_MAX_BYTES: + raise OSError(f"committed hook has invalid size: {source}") + # `cat-file blob` returns the committed bytes without textconv/filter + # execution. `git show` is presentation porcelain and may consult local + # diff-driver configuration, which is not a trusted boot-time code path. + show = _hook_git(repo, "cat-file", "blob", blob) + if show.returncode != 0: + raise OSError(_hook_command_error(show)) + if len(show.stdout) != size or not show.stdout.startswith(b"#!"): + raise OSError(f"committed hook failed verification: {source}") + sources.append((destination, show.stdout)) + + common = _hook_git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir") + if common.returncode != 0: + raise OSError(_hook_command_error(common)) + common_dir = Path(os.fsdecode(common.stdout.strip())).resolve(strict=True) + hooks_dir = common_dir / "hooks" + if hooks_dir.is_symlink(): + raise OSError("refusing symlinked git hooks directory") + hooks_dir.mkdir(mode=0o755, parents=True, exist_ok=True) + if not hooks_dir.is_dir(): + raise OSError("git hooks path is not a directory") + + lock_path = hooks_dir / ".mobius-refresh.lock" + with lock_path.open("a+b") as lock_handle: + try: + fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return "" # The concurrent refresher owns the same complete operation. + + previous = { + name: _read_hook_destination(hooks_dir / name) + for name, _data in sources + } + staged: dict[str, Path] = {} + try: + for name, data in sources: + staged[name] = _stage_hook_file(hooks_dir, data) + except Exception: + for path in staged.values(): + path.unlink(missing_ok=True) + raise + replaced: list[str] = [] + try: + try: + for name, _data in sources: + os.replace(staged[name], hooks_dir / name) + replaced.append(name) + except Exception: + for name in reversed(replaced): + _restore_hook_destination(hooks_dir / name, previous[name]) + raise + # A repository-local hooksPath takes effect only after the complete set + # exists. On refresh each destination changes by atomic inode swap, so a + # concurrent Git process sees either the previous hook or the new hook, + # never an absent path. + configured = _hook_git( + repo, "config", "--local", "core.hooksPath", str(hooks_dir), + ) + if configured.returncode != 0: + raise OSError(_hook_command_error(configured)) + finally: + for path in staged.values(): + path.unlink(missing_ok=True) + return "" + + +def _refresh_git_hooks(repo: Path, source_oid: str | None) -> str | None: + """Best-effort hook refresh that is total at boot and after committed Apply.""" + if not source_oid: + return None + try: + return _refresh_git_hooks_impl(repo, source_oid) + except Exception as exc: + return repr(exc)[:500] + + async def _rebuild_frontend_after_update_if_needed( repo: Path, res: ReconcileResult, ) -> None: @@ -897,7 +1074,15 @@ def _reconcile_under_lock(repo: Path, at_boot: bool) -> ReconcileResult: """Hold :data:`RECONCILE_LOCK` around one reconcile so the boot subprocess and the running uvicorn's Apply can never run two reconciles on the same repo.""" with _reconcile_flock(): - return reconcile_clone(repo, at_boot=at_boot) + result = reconcile_clone(repo, at_boot=at_boot) + # `upstream` is moved only by a successful/contained reconcile to the + # fetched release target. Capture its immutable oid before releasing the + # cross-process lock; local replay commits on main are intentionally not a + # hook trust transition. + return replace( + result, + hook_source_sha=_rev(repo, UPSTREAM_BRANCH) or None, + ) def _short(sha: str | None) -> str: @@ -911,10 +1096,18 @@ def reconcile_clone_sync() -> str: worst case leaves the pre-reconcile code serving and a flag set.""" try: res = _reconcile_under_lock(PLATFORM_REPO, at_boot=True) + # Even an offline/conflict pass leaves a complete served tree on disk. Hook + # refresh is local-only, so do it on every boot rather than waiting for a + # successful fetch that may be unrelated to the stale installed copy. + hook_refresh = _refresh_git_hooks(PLATFORM_REPO, res.hook_source_sha) summary = ( f"reconcile[{res.status}] pre={_short(res.pre_sha)} " f"new={_short(res.new_sha)} target={_short(res.target_sha)}" ) + if hook_refresh == "": + summary += " hooks=refreshed" + elif hook_refresh: + summary += f" hooks=error:{hook_refresh}" if res.conflict_paths: summary += f" conflicts={len(res.conflict_paths)}" if res.error: @@ -1159,6 +1352,11 @@ async def apply_platform_update( chat_id: str | None = None if res.status == "updated": + hook_refresh = await asyncio.to_thread( + _refresh_git_hooks, repo, res.hook_source_sha, + ) + if hook_refresh: + log.warning("git hook refresh failed after platform update: %s", hook_refresh) # Frontend changes rebuild into dist (served per-request, no restart); # only a served-backend or constitution change requires restarting # uvicorn. Path-aware so test/docs/frontend-only updates finish without a diff --git a/backend/app/providers.py b/backend/app/providers.py index baecbdede..637439d63 100644 --- a/backend/app/providers.py +++ b/backend/app/providers.py @@ -53,6 +53,8 @@ # source of truth for newly released IDs. KNOWN_MODELS = { "claude": [ + "claude-fable-5", + "claude-sonnet-5", # Anthropic switched to dateless pinned IDs starting with 4.6; # the dated entries below stay listed because existing chats # persist them in agent_settings_json and the API still resolves @@ -84,6 +86,8 @@ # Codex's models() returns slugs only), so labels come from this map # when present and fall back to the raw ID for newly released models. MODEL_LABELS: dict[str, str] = { + "claude-fable-5": "Fable 5", + "claude-sonnet-5": "Sonnet 5", "claude-opus-4-8": "Opus 4.8", "claude-opus-4-7": "Opus 4.7", "claude-opus-4-6": "Opus 4.6", @@ -107,8 +111,42 @@ # registry carries it to every shell/app picker as data. MODEL_EFFORT_LEVELS: dict[str, list[str]] = {} +# Runtime recovery defaults are intentionally independent of picker order. +# Fable is presented first in the interactive picker, but a stale/mismatched +# saved value must not silently opt an unattended retry into usage credits. DEFAULT_MODELS = { - provider: models[0] for provider, models in KNOWN_MODELS.items() + "claude": "claude-opus-4-8", + "codex": "gpt-5.6-sol", +} + +# Curated first-run model visibility. The registry remains broader so an +# existing chat can keep rendering an older saved model and the owner can +# reveal any hidden row from Settings. An explicit owner preference (including +# an explicit empty hidden list) always wins over this starter set. +DEFAULT_VISIBLE_MODEL_ORDER: dict[str, tuple[str, ...]] = { + "claude": ( + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + ), + "codex": ( + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + ), +} +DEFAULT_VISIBLE_MODELS: dict[str, frozenset[str]] = { + provider_id: frozenset(models) + for provider_id, models in DEFAULT_VISIBLE_MODEL_ORDER.items() +} + +# Unattended work gets deliberately conservative provider-specific defaults, +# independent of the first model shown in the interactive chat picker. +DEFAULT_BACKGROUND_MODELS = { + "claude": "claude-opus-4-8", + "codex": "gpt-5.6-terra", } # Initial effort when no global default exists. Aligns with the @@ -173,6 +211,23 @@ def _load_agent_settings(data_dir: str) -> dict: return {} +def hidden_model_ids(model_prefs: Any) -> list[str]: + """Resolve model-picker visibility for an owner. + + Missing preferences use the curated starter set above. Once the owner saves + Manage models, even ``{"hidden_ids": []}`` is explicit and means show all. + """ + if isinstance(model_prefs, dict) and "hidden_ids" in model_prefs: + raw = model_prefs.get("hidden_ids") + return [entry for entry in (raw or []) if isinstance(entry, str)] + return [ + model_id + for provider_id, models in KNOWN_MODELS.items() + for model_id in models + if model_id not in DEFAULT_VISIBLE_MODELS.get(provider_id, frozenset()) + ] + + def skills_enabled(data_dir: str) -> bool: """Whether SDK skills are offered to the Claude agent (default OFF). @@ -304,7 +359,7 @@ def _background_default_choice( ) -> dict: return { "provider": provider, - "model": model, + "model": model if model is not None else DEFAULT_BACKGROUND_MODELS.get(provider), "effort": DEFAULT_EFFORT, "enabled": enabled, } @@ -328,14 +383,14 @@ def _clean_background_choice( if isinstance(raw_model, str) and raw_model.strip(): model = raw_model.strip() if _model_belongs_to_other_provider(model, provider): - model = DEFAULT_MODELS.get(provider) + model = DEFAULT_BACKGROUND_MODELS.get(provider) elif "model" in raw: # Explicit null/empty means "let this provider use its native default". model = None else: # Legacy provider-only choices predate nullable model defaults; keep them # concrete so background runners do not inherit the chat model by accident. - model = DEFAULT_MODELS.get(provider) + model = DEFAULT_BACKGROUND_MODELS.get(provider) out["model"] = model effort = raw.get("effort") out["effort"] = effort.strip() if isinstance(effort, str) and effort.strip() else None @@ -369,14 +424,6 @@ def background_agent_settings(data_dir: str, default_provider: str | None = None file_layer = _load_agent_settings(data_dir) raw = file_layer.get("background_agents") bg = raw if isinstance(raw, dict) else {} - # When the owner has already picked a chat model, synthesize a concrete - # provider-native background model rather than inheriting that chat default. - # With no manual model choice at all, keep the background model nullable so - # the provider SDK can use its own default until the owner saves a row. - synthetic_default_model = ( - DEFAULT_MODELS.get(provider) if "model" in file_layer else None - ) - rows: list[dict[str, Any]] = [] seen: set[str] = set() @@ -410,7 +457,6 @@ def add_row(choice: dict | None, *, enabled_default: bool) -> None: primary = _background_default_choice( provider, enabled=True, - model=synthetic_default_model, ) add_row(primary, enabled_default=True) add_row(_clean_background_choice(bg.get("fallback")), enabled_default=True) @@ -420,7 +466,6 @@ def add_row(choice: dict | None, *, enabled_default: bool) -> None: _background_default_choice( provider, enabled=True, - model=synthetic_default_model, ), enabled_default=True, ) @@ -431,7 +476,7 @@ def add_row(choice: dict | None, *, enabled_default: bool) -> None: _background_default_choice( provider_id, enabled=False, - model=DEFAULT_MODELS.get(provider_id), + model=DEFAULT_BACKGROUND_MODELS.get(provider_id), ) ) @@ -835,6 +880,13 @@ def _live_model_entries( succeeds, the provider SDK/CLI is the source of truth; labels are a cosmetic map with raw-ID fallback. """ + # The curated compatibility aliases are an owner-chosen product surface, not + # a mirror of one catalog response. Keep them available even when a provider + # temporarily omits an older-but-still-supported alias (Sonnet 4.6 / GPT-5.5) + # from discovery, then append every genuinely live extra in provider order. + preferred = DEFAULT_VISIBLE_MODEL_ORDER.get(provider_id, ()) + ordered_ids = list(preferred) + ordered_ids.extend(model_id for model_id in live_ids if model_id not in preferred) return [ { "id": mid, @@ -844,7 +896,7 @@ def _live_model_entries( **({"effort_levels": MODEL_EFFORT_LEVELS[mid]} if mid in MODEL_EFFORT_LEVELS else {}), } - for mid in live_ids + for mid in ordered_ids ] diff --git a/backend/app/push.py b/backend/app/push.py index b1317076b..e308f0385 100644 --- a/backend/app/push.py +++ b/backend/app/push.py @@ -161,6 +161,13 @@ def notify_owner( sent_at=datetime.now(UTC), ) db.add(notif) + # App background jobs already attribute their success/failure notification + # to the app. Make that canonical completion edge own the drawer dot too, + # rather than requiring every cron script to remember a parallel signal. + from app.app_activity import mark_from_notification + activity_app_id = mark_from_notification( + db, source_type=source_type, source_id=source_id, + ) try: db.commit() except Exception: @@ -185,6 +192,14 @@ def notify_owner( pass return notification_id + if activity_app_id is not None: + # The row above is durable; this replay-free event only makes a live shell + # refetch immediately. A reconnect/boot refetch recovers a missed event. + from app.broadcast import get_system_broadcast + get_system_broadcast().publish({ + "type": "app_activity", "appId": str(activity_app_id), + }) + # Skip push when a live SSE subscriber is already watching the # source chat — the in-tab UX surfaces the event there. presence # owns this contract so we don't have to reach across modules diff --git a/backend/app/routes/apps.py b/backend/app/routes/apps.py index d007806b7..24c50a27c 100644 --- a/backend/app/routes/apps.py +++ b/backend/app/routes/apps.py @@ -19,11 +19,12 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app import ( - activity, app_git, app_jobs, fs_locks, icon_cache, legacy_platform_apps, + activity, app_activity, app_git, app_jobs, fs_locks, icon_cache, + legacy_platform_apps, models, providers, schemas, source_dirs, theme, ) @@ -410,6 +411,11 @@ async def _hard_delete_app(db: Session, app: models.App) -> None: # cleanup of the slug-keyed source tree below leaves harmless orphans — those # are not addressable by a reused integer id, so a live row pointing at # missing files (a 404) is the acceptable failure, not data exposure. + # The activity marker is id-keyed too; remove it before the reusable app id + # is freed so a future unrelated app never inherits the old app's dot. + db.query(models.AppActivityState).filter( + models.AppActivityState.app_id == deleted_app_id, + ).delete(synchronize_session=False) db.delete(app) db.commit() get_system_broadcast().publish( @@ -671,7 +677,7 @@ async def list_apps( "hard-delete purge failed for app %s; leaving tombstone", app.id ) db.rollback() - return ( + apps = ( db.query(models.App) .filter(models.App.deleted_at.is_(None)) .order_by( @@ -681,6 +687,7 @@ async def list_apps( ) .all() ) + return app_activity.annotate_apps(db, apps) @router.get("/schedules", response_model=list[schemas.AppScheduleOut]) @@ -1813,7 +1820,29 @@ def get_app( ): """Returns a single mini-app by ID (404 for a tombstoned one).""" app = live_app_or_404(db, app_id) - return app + return app_activity.annotate_apps(db, [app])[0] + + +class AppActivitySeenRequest(BaseModel): + activity_version: int = Field(ge=1, le=(2**63 - 1)) + + +@router.post( + "/{app_id}/activity/seen", + status_code=204, + dependencies=[Depends(reject_cross_site)], +) +def mark_app_activity_seen( + app_id: int, + body: AppActivitySeenRequest, + db: Session = Depends(get_db), + _: models.Owner = Depends(get_current_owner), +): + """Clear an app's durable activity dot when the owner opens the app.""" + live_app_or_404(db, app_id) + app_activity.mark_seen(db, app_id, body.activity_version) + db.commit() + return Response(status_code=204) @router.patch( diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 69ad69e11..fa68ff36e 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -622,11 +622,8 @@ async def providers_models( from app.providers import list_models data_dir = get_settings().data_dir registry = await list_models(data_dir) - prefs = owner.model_prefs_json or {} - hidden_ids = { - entry for entry in (prefs.get("hidden_ids") or []) - if isinstance(entry, str) - } + from app.providers import hidden_model_ids + hidden_ids = set(hidden_model_ids(owner.model_prefs_json)) out: dict[str, list[dict[str, str]]] = {} for provider_id, entries in registry.items(): rows: list[dict[str, str]] = [] diff --git a/backend/app/routes/client_error.py b/backend/app/routes/client_error.py index 2a071a13d..00e7c067a 100644 --- a/backend/app/routes/client_error.py +++ b/backend/app/routes/client_error.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from app import activity +from app.chat_log_redaction import scrub_secrets from app.deps import Principal, get_principal, reject_cross_site router = APIRouter(prefix="/api/client-error", tags=["client-error"]) @@ -44,16 +45,19 @@ def report_client_error( that differ only past the cap still collapse — so a render loop can't flood the log. """ - message = body.message[:_MSG_MAX] + # Treat the server as the final retention boundary. A stale or hostile + # client can bypass the frame scrubber, so scrub every retained text field + # before debounce, truncation, and the activity.jsonl write. + message = scrub_secrets(body.message)[:_MSG_MAX] if not activity.should_emit_app_error(principal.app_id, message): return # debounced — already recorded within the window; still 204 fields: dict[str, object] = {"message": message} if principal.app_id is not None: fields["app_id"] = principal.app_id if body.where: - fields["where"] = body.where[:_WHERE_MAX] + fields["where"] = scrub_secrets(body.where)[:_WHERE_MAX] if body.stack: - fields["stack"] = body.stack[:_STACK_MAX] + fields["stack"] = scrub_secrets(body.stack)[:_STACK_MAX] if body.url: - fields["url"] = body.url[:_URL_MAX] + fields["url"] = scrub_secrets(body.url)[:_URL_MAX] activity.log_event("app_error", **fields) diff --git a/backend/app/routes/github.py b/backend/app/routes/github.py index dff1a2aea..142f57001 100644 --- a/backend/app/routes/github.py +++ b/backend/app/routes/github.py @@ -1032,15 +1032,165 @@ def _parse_pr_number(url: str) -> int | None: return int(m.group(1)) if m else None +def _reviewed_pr_labels(plan: dict) -> list[str]: + """Return only the two labels the owner could see in Contribute review.""" + raw = plan.get("labels") + if not isinstance(raw, list): + return [] + # Mirror Contribute's review surface: it filters malformed/blank values, + # trims them, and then shows at most two. Security validation and duplicate + # folding happen only after that visibility boundary, so an unseen third + # label can never replace a visible-but-unusable one at submit time. + visible = [] + for value in raw: + if not isinstance(value, str): + continue + label = value.strip() + if not label: + continue + visible.append(label) + if len(visible) == 2: + break + labels = [] + seen = set() + for label in visible: + folded = label.casefold() + if len(label) > 50 or "\n" in label or folded in seen: + continue + seen.add(folded) + labels.append(label) + return labels + + +def _apply_reviewed_pr_labels( + repo: Path, + upstream_repo: str, + number: int | None, + labels: list[str], +) -> dict: + """Best-effort add reviewed labels that already exist in the target repo. + + Labeling is deliberately secondary to PR creation: a missing repository + label, permission restriction, or transient API failure must not turn an + already-open pull request into an apparent failed submission. The outcome is + persisted so the review never claims an unavailable label was applied. + """ + if not labels: + return {} + patch = { + "last_submit_labels_requested": labels, + "last_submit_labels_applied": [], + } + if number is None: + return { + **patch, + "last_submit_labels_note": "GitHub did not return a PR number for labeling.", + } + + try: + available = _gh( + repo, + "api", "--paginate", + f"repos/{upstream_repo}/labels?per_page=100", + "--jq", ".[].name", + check=False, + ) + except subprocess.TimeoutExpired: + return { + **patch, + "last_submit_labels_note": ( + "Timed out while checking repository labels; the pull request is " + "open without confirmed labels." + ), + } + except OSError: + return { + **patch, + "last_submit_labels_note": ( + "Could not start the GitHub label lookup; the pull request is open " + "without confirmed labels." + ), + } + if available.returncode != 0: + return { + **patch, + "last_submit_labels_note": ( + "Could not verify the repository labels; the pull request is open " + "without confirmed labels." + ), + } + by_name = {} + for raw_name in (available.stdout or "").splitlines(): + name = raw_name.strip() + if name: + by_name[name.casefold()] = name + applicable = [by_name[label.casefold()] for label in labels + if label.casefold() in by_name] + missing = [label for label in labels if label.casefold() not in by_name] + if not applicable: + return { + **patch, + "last_submit_labels_missing": missing, + "last_submit_labels_note": "The reviewed labels do not exist in this repository.", + } + + try: + applied = _gh( + repo, + "api", "--method", "POST", + f"repos/{upstream_repo}/issues/{number}/labels", + *(part for label in applicable for part in ("-f", f"labels[]={label}")), + check=False, + ) + except subprocess.TimeoutExpired: + return { + **patch, + "last_submit_labels_missing": missing, + "last_submit_labels_note": ( + "Timed out while applying reviewed labels; the pull request is open, " + "but GitHub did not confirm the label result." + ), + } + except OSError: + return { + **patch, + "last_submit_labels_missing": missing, + "last_submit_labels_note": ( + "Could not start the GitHub label update; the pull request is open " + "without confirmed labels." + ), + } + if applied.returncode != 0: + return { + **patch, + "last_submit_labels_missing": missing, + "last_submit_labels_note": ( + "GitHub did not confirm these labels were applied; the pull request " + "is still open." + ), + } + result = { + **patch, + "last_submit_labels_applied": applicable, + } + if missing: + result["last_submit_labels_missing"] = missing + result["last_submit_labels_note"] = "Some reviewed labels no longer exist." + return result + + def _find_existing_pr( repo: Path, upstream_repo: str, login: str, branch: str, *, + expected_head_sha: str, base_branch: str | None = None, same_repo: bool = False, ) -> str | None: + if not _GIT_SHA.match(str(expected_head_sha or "")): + return None head = branch if same_repo else f"{login}:{branch}" args = [ "pr", "list", @@ -1049,22 +1199,32 @@ def _find_existing_pr( ] if base_branch: args.extend(("--base", _validate_branch(base_branch))) - args.extend(("--state", "open", "--json", "url", "--limit", "1")) - proc = _gh( - repo, - *args, - check=False, - ) + args.extend(( + "--state", "open", "--json", "url,headRefOid", "--limit", "10", + )) + try: + proc = _gh( + repo, + *args, + check=False, + ) + except (subprocess.TimeoutExpired, OSError): + return None if proc.returncode != 0: return None try: rows = json.loads(proc.stdout or "[]") except ValueError: return None - if isinstance(rows, list) and rows: - url = rows[0].get("url") if isinstance(rows[0], dict) else None - if isinstance(url, str) and url.startswith("https://github.com/"): - return url + if isinstance(rows, list): + for row in rows: + if not isinstance(row, dict): + continue + if str(row.get("headRefOid") or "") != expected_head_sha: + continue + url = row.get("url") + if isinstance(url, str) and url.startswith("https://github.com/"): + return url return None @@ -1621,6 +1781,14 @@ def _submit_prepared_pr( record_patch = _record_patch_with(record_patch, merge_patch) except ContributionSubmitError as exc: raise _merge_error_patch(exc, record_patch) from exc + # The merge preflight proves one exact upstream base. Pin that same branch + # into both create and ambiguous-response recovery. Without an explicit + # standalone --base, gh may honor stale branch..gh-merge-base config + # from the durable staging checkout and publish the reviewed diff against a + # different target. + submit_base = direct_base or _validate_branch( + str(merge_patch.get("last_submit_upstream_branch") or "") + ) push_source = "HEAD" if direct_base: @@ -1674,6 +1842,20 @@ def _submit_prepared_pr( ), "last_pushed_branch_url": pushed_branch_url, } + pushed_sha = str( + pushed_patch.get("last_submit_push_sha") + or pushed_patch.get("head_sha") + or plan.get("head_sha") + or "" + ).strip() + if not _GIT_SHA.match(pushed_sha): + pushed_sha = _git(repo, "rev-parse", push_source).stdout.strip() + if not _GIT_SHA.match(pushed_sha): + raise ContributionSubmitError( + "Could not verify the exact reviewed commit after pushing this branch.", + record_patch=pushed_patch, + ) + pushed_patch["last_submit_push_sha"] = pushed_sha with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as f: f.write(body) @@ -1687,23 +1869,51 @@ def _submit_prepared_pr( "--title", title, "--body-file", body_file, ] - if direct_base: - create_args.extend(("--base", direct_base)) - pr = _gh(repo, *create_args, check=False) - if pr.returncode != 0: + create_args.extend(("--base", submit_base)) + create_transport_error = None + try: + pr = _gh(repo, *create_args, check=False) + except subprocess.TimeoutExpired: + pr = None + create_transport_error = ( + "Timed out while waiting for GitHub to confirm pull request creation." + ) + except OSError: + pr = None + create_transport_error = ( + "Could not start the GitHub pull request creation command." + ) + if pr is None or pr.returncode != 0: # Retried sends commonly arrive after GitHub already created the PR. - # Pay for the list lookup only on this uncommon recovery path. + # A create transport failure is also ambiguous: GitHub may have + # accepted the request before the local process lost its response. + # Probe the reviewed branch and require its exact pushed commit before + # treating the PR as open. Never issue a second create in this call. existing = _find_existing_pr( repo, upstream_repo, login, branch, - base_branch=direct_base, + expected_head_sha=pushed_sha, + base_branch=submit_base, same_repo=bool(direct_base), ) if existing: - return existing, _parse_pr_number(existing), pushed_patch - detail = (pr.stderr or pr.stdout or "GitHub command failed.").strip() + existing_number = _parse_pr_number(existing) + label_patch = _apply_reviewed_pr_labels( + repo, + upstream_repo, + existing_number, + _reviewed_pr_labels(plan), + ) + return ( + existing, + existing_number, + _record_patch_with(pushed_patch, label_patch), + ) + detail = create_transport_error or ( + pr.stderr or pr.stdout or "GitHub command failed." + ).strip() raise ContributionSubmitError(detail[:600] or "GitHub command failed.") except ContributionSubmitError as exc: raise ContributionSubmitError( @@ -1723,7 +1933,14 @@ def _submit_prepared_pr( f"to {pushed_branch_url}.", record_patch=pushed_patch, ) - return url, _parse_pr_number(url), pushed_patch + number = _parse_pr_number(url) + label_patch = _apply_reviewed_pr_labels( + repo, + upstream_repo, + number, + _reviewed_pr_labels(plan), + ) + return url, number, _record_patch_with(pushed_patch, label_patch) finally: if checkout_back: _git(repo, "checkout", "-q", checkout_back, check=False) diff --git a/backend/app/routes/settings.py b/backend/app/routes/settings.py index 4aa82d6f7..997cb3406 100644 --- a/backend/app/routes/settings.py +++ b/backend/app/routes/settings.py @@ -373,14 +373,11 @@ def get_model_prefs( ) -> dict: """Returns the owner's model-picker preferences. - Default shape is `{"hidden_ids": []}` — absent prefs and empty - prefs are equivalent (the picker shows every registry entry). + Owners without a saved preference receive the curated default hidden set. + An explicitly saved `{"hidden_ids": []}` is distinct and shows every + registry entry. """ - prefs = owner.model_prefs_json or {} - hidden = prefs.get("hidden_ids") or [] - # Defensive normalize: any persisted non-string falls out here so - # the client never sees a malformed entry. - return {"hidden_ids": [s for s in hidden if isinstance(s, str)]} + return {"hidden_ids": providers.hidden_model_ids(owner.model_prefs_json)} @owner_router.patch("/model-prefs", dependencies=[Depends(reject_cross_site)]) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a58b942b6..d986eacd6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -119,6 +119,10 @@ class AppOut(BaseModel): chat_id: str | None = None source_dir: str | None = None pinned_at: datetime | None = None + # A durable app-attributed notification landed since this app was last + # opened. The shell renders the same quiet activity dot used for chats. + has_unseen_activity: bool = False + unseen_activity_version: int | None = None cross_app_access: ShareLevel = "none" share_with_apps: ShareLevel = "none" offline_capable: bool = False diff --git a/backend/recovery/recovery_chat_runner.py b/backend/recovery/recovery_chat_runner.py index c6d2aa9a3..e15e60f4a 100644 --- a/backend/recovery/recovery_chat_runner.py +++ b/backend/recovery/recovery_chat_runner.py @@ -104,6 +104,8 @@ # picker always allows "CLI default" (no --model) too. RECOVERY_MODELS: dict[str, tuple[str, ...]] = { "claude": ( + "claude-fable-5", + "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", diff --git a/backend/scripts/reflection-brief-template.html b/backend/scripts/reflection-brief-template.html index d3f22eccf..0244fdf0c 100644 --- a/backend/scripts/reflection-brief-template.html +++ b/backend/scripts/reflection-brief-template.html @@ -11,9 +11,10 @@ The chat lives BELOW this brief: the Reflection app renders this static page, then mounts the morning-chat thread underneath it. Keep this page a clean READ. The - structured decisions ride in the brief as a declarative question carrier (the - data-report-questions section near the end) — the app lifts that out and renders - native tap cards whose answers are saved for the NEXT run. Do NOT call + A genuinely necessary structured decision can ride in the brief as an optional + declarative question carrier — the app lifts that out and renders native tap + cards whose answers are saved for the NEXT run. The default template asks + nothing. Do NOT call AskUserQuestion from the nightly run (it parks a future a reset orphans). The morning chat is the open-ended escape hatch for anything the cards can't capture. @@ -581,50 +582,10 @@

{{TITLE}}

- -
-
4

What needs your input

-

Decisions I didn’t make for you. Answer these as tappable cards at the end of this brief — one tap each. Your picks guide tomorrow night, not today.

-
- -
- Decide {{INPUT_TITLE_1}} -

{{INPUT_DESC_1}}

-
-
Trigger
{{INPUT_TRIGGER_1}}
-
Why
{{INPUT_WHY_1}}
-
Options
{{INPUT_OPTIONS_1}}
-
-

Tap your answer in the question card at the end of this brief — it’s saved for tomorrow night’s run.

-
- -
- - -
-

A few questions for tomorrow night

-

Your answers guide my next run — they won't change this brief.

- -
- -
+
diff --git a/backend/scripts/seed-skills/reflection.md b/backend/scripts/seed-skills/reflection.md index 90c99fb98..a7b867af7 100644 --- a/backend/scripts/seed-skills/reflection.md +++ b/backend/scripts/seed-skills/reflection.md @@ -76,7 +76,23 @@ Read `inputs/meta-state.md` first. It is your compact current operating model of Read `inputs/resource-snapshot.json`, `inputs/resource-history.jsonl`, and `inputs/resource-decisions.jsonl` in the same pass. The snapshot already paid for tonight's observation: it always contains cheap disk/cgroup counters and contains a bounded deep `/data` inventory only when due, under pressure, or after unusual growth. The history supplies recent trends and the last deep inventory; the decisions ledger says what prior runs changed, the measured result, when to look again, and what trigger permits an earlier review. **Do not rerun broad `du`, recursive `find`, browser sweeps, or equivalent diagnostics when the snapshot is fresh and the relevant decision is neither due nor triggered.** Missing or failed telemetry is a reason to repair telemetry, not permission to launch an unbounded scan. -Also read `inputs/prev-question-answers.json` here (present when the partner tapped a recent brief's question cards). Those answers were saved for THIS run — no live agent waited on them. Note each decision now and **act on it in phase 2**: build the feature they picked, apply the fix they approved, drop the declines. Absent on first runs or when no questions were asked → move on. +Also read `inputs/prev-question-answers.json` here when present. Those answers +were saved for THIS run — no live agent waited on them. Note each decision now +and **act on it in phase 2**: build the feature they picked, apply the fix they +approved, drop the declines. The staged file is the newest answer record, not +necessarily an answer to `prev-report.html`; use its `report_date` when relating +it to a particular brief. + +**Question-engagement evidence must be report-aligned.** Read +`inputs/prev-report-name.txt` for the previous report's date and inspect +`inputs/prev-report.html` for a valid, non-empty +`application/mobius-questions+json` carrier. Infer that the previous brief's +cards were unanswered only when that exact report really contained questions +and no staged answer record has the same `report_date`. A missing carrier or an +empty questions array means the run asked nothing, so it supplies no +non-response evidence. A mismatched older answer record proves neither that the +previous brief asked questions nor that its cards were ignored. One unanswered +brief is a weak channel signal, never a durable partner preference. ### 1. INTROSPECTION — interview the agents worth interviewing (summary-first triage) @@ -394,7 +410,16 @@ Copy this skeleton — the template (and the base style the app injects into eve Be ruthless below the lede: a section with nothing that clears the trigger/why/next-action bar gets deleted, not padded, and a one-item night is a fine brief. The exec-summary is never collapsed; everything else defaults shut. -**Honor the brief-style setting.** Use the `verbosity` value supplied in tonight's goal from canonical numeric app storage (`/data/apps//settings.json`): `terse` = TL;DR plus keypoints plus only the must-act items, everything else dropped entirely; `standard` = the default above; `chatty` = the partner has opted into more narrative, so the lead paragraphs *inside* the collapsed items may run longer (the TL;DR cap and collapsed-by-default details still hold). Do not read settings from the `/data/apps/reflection` source directory. Absent or unrecognized → treat as `standard`. +**Adapt the brief instead of obeying a fixed style control.** Start concise: a +TL;DR, keypoints, and only items that clear the trigger/why/next-action bar. +Compare `prev-report.html` with what changed tonight; compress repeated context +and spend detail only where it improves a decision or explains a concrete +result. Use the report-aligned engagement evidence above to ask fewer, sharper +questions when cards are low-yield, but do not treat one unanswered brief as a +request for less writing. The collapsed details can carry necessary narrative; +the TL;DR cap and collapsed-by-default contract remain fixed. There is no +`verbosity`, `focus`, or `avoid` setting to honor — editorial judgment belongs +to the Reflection agent each run. **Put the questions IN the brief as tappable cards — the in-report contract.** The partner answers your decisions by tapping cards rendered *in the brief itself*, and those answers are saved for your **NEXT run** — not collected by a live agent. This is the durable replacement for the old "post AskUserQuestion cards in a morning chat" flow: a background/morning agent that calls `AskUserQuestion` parks a synchronous in-memory future that a server reset orphans, freezing the night. Instead, **emit the questions declaratively inside the brief HTML** and let the app render the cards. diff --git a/backend/tests/test_app_activity.py b/backend/tests/test_app_activity.py new file mode 100644 index 000000000..413e01673 --- /dev/null +++ b/backend/tests/test_app_activity.py @@ -0,0 +1,140 @@ +"""App-attributed notifications drive a durable drawer activity marker.""" + +from app import models +from app.broadcast import get_system_broadcast + + +def _app(db): + app = models.App( + name="News", description="", jsx_source="export default function App(){}", + compiled_path="/tmp/app.js", + ) + db.add(app) + db.commit() + db.refresh(app) + return app + + +def test_app_notification_marks_list_unseen_and_open_acknowledges( + client, auth, db, +): + app = _app(db) + system_bus = get_system_broadcast() + events = system_bus.subscribe() + + try: + sent = client.post("/api/notifications/send", headers=auth, json={ + "title": "News digest ready", + "source_type": "app", + "source_id": str(app.id), + "target": f"/shell/?app={app.id}", + }) + assert sent.status_code == 200, sent.text + assert events.get_nowait() == { + "type": "app_activity", "appId": str(app.id), + } + finally: + system_bus.unsubscribe(events) + + listed = client.get("/api/apps/", headers=auth) + assert listed.status_code == 200, listed.text + row = next(item for item in listed.json() if item["id"] == app.id) + assert row["has_unseen_activity"] is True + observed_version = row["unseen_activity_version"] + + seen = client.post( + f"/api/apps/{app.id}/activity/seen", + headers=auth, + json={"activity_version": observed_version}, + ) + assert seen.status_code == 204, seen.text + row = next( + item for item in client.get("/api/apps/", headers=auth).json() + if item["id"] == app.id + ) + assert row["has_unseen_activity"] is False + + +def test_late_seen_request_does_not_erase_newer_app_activity(client, auth, db): + app = _app(db) + payload = { + "title": "Background work finished", + "source_type": "app", + "source_id": str(app.id), + } + assert client.post("/api/notifications/send", headers=auth, json=payload).status_code == 200 + first = db.get(models.AppActivityState, app.id) + db.refresh(first) + observed_version = first.activity_version + + # A second completion lands after the shell fetched the first marker but + # before its acknowledgement reaches the server. + assert client.post("/api/notifications/send", headers=auth, json=payload).status_code == 200 + db.refresh(first) + newer_version = first.activity_version + assert newer_version == observed_version + 1 + + stale = client.post( + f"/api/apps/{app.id}/activity/seen", + headers=auth, + json={"activity_version": observed_version}, + ) + assert stale.status_code == 204 + db.refresh(first) + assert first.unseen is True + + current = client.post( + f"/api/apps/{app.id}/activity/seen", + headers=auth, + json={"activity_version": newer_version}, + ) + assert current.status_code == 204 + db.refresh(first) + assert first.unseen is False + + +def test_seen_rejects_versions_outside_sqlite_integer_range(client, auth, db): + app = _app(db) + sent = client.post("/api/notifications/send", headers=auth, json={ + "title": "Background work finished", + "source_type": "app", + "source_id": str(app.id), + }) + assert sent.status_code == 200, sent.text + + for invalid_version in (0, -1, 1 << 63, 10**80): + response = client.post( + f"/api/apps/{app.id}/activity/seen", + headers=auth, + json={"activity_version": invalid_version}, + ) + assert response.status_code == 422, response.text + + state = db.get(models.AppActivityState, app.id) + db.refresh(state) + assert state.unseen is True + + +def test_non_app_and_unknown_app_notifications_do_not_create_markers( + client, auth, db, +): + app = _app(db) + for source_type, source_id in ( + ("system", None), + ("app", "999"), + ("app", "0"), + ("app", "999999999999999999999999999999999999"), + ): + payload = {"title": "Background work finished", "source_type": source_type} + if source_id is not None: + payload["source_id"] = source_id + sent = client.post("/api/notifications/send", headers=auth, json=payload) + assert sent.status_code == 200, sent.text + + row = next( + item for item in client.get("/api/apps/", headers=auth).json() + if item["id"] == app.id + ) + assert row["has_unseen_activity"] is False + assert db.query(models.AppActivityState).count() == 0 + assert db.query(models.Notification).count() == 4 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index c7d4d0ddb..a4ffed14a 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -149,7 +149,7 @@ def test_providers_models_accepts_app_token(client, auth): app_id = r0.json()["id"] from app.auth import create_access_token - from app.providers import KNOWN_MODELS, invalidate_model_cache + from app.providers import DEFAULT_VISIBLE_MODELS, invalidate_model_cache invalidate_model_cache() app_token = create_access_token({ "sub": "test", "scope": "app", "app_id": app_id, @@ -160,9 +160,10 @@ def test_providers_models_accepts_app_token(client, auth): ) assert r.status_code == 200, r.text body = r.json() - # Full per-provider list, not a one-model FALLBACK_GROUPS stub. - assert [m["id"] for m in body["claude"]] == KNOWN_MODELS["claude"] - assert [m["id"] for m in body["codex"]] == KNOWN_MODELS["codex"] + # The same curated defaults the owner sees, not a one-model fallback stub. + assert {m["id"] for m in body["claude"]} == DEFAULT_VISIBLE_MODELS["claude"] + assert {m["id"] for m in body["codex"]} == DEFAULT_VISIBLE_MODELS["codex"] + assert len(body["claude"]) > 1 and len(body["codex"]) > 1 def test_providers_status_accepts_app_token(client, auth): @@ -339,22 +340,28 @@ def test_providers_models_returns_known_models_on_missing_creds( `list_models` falls back to KNOWN_MODELS — exercise that path and pin the response shape mini-apps depend on (id + name, plus a tier on Claude rows).""" - from app.providers import KNOWN_MODELS, invalidate_model_cache + from app.providers import DEFAULT_VISIBLE_MODELS, KNOWN_MODELS, invalidate_model_cache invalidate_model_cache() r = client.get("/api/auth/providers/models", headers=auth) assert r.status_code == 200 body = r.json() assert set(body) == {"claude", "codex"} claude_ids = [m["id"] for m in body["claude"]] - assert claude_ids == KNOWN_MODELS["claude"] + assert claude_ids == [ + "claude-fable-5", "claude-sonnet-5", + "claude-opus-4-8", "claude-sonnet-4-6", + ] codex_ids = [m["id"] for m in body["codex"]] - assert codex_ids == KNOWN_MODELS["codex"] + assert codex_ids == [ + "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", + ] + assert set(claude_ids) == DEFAULT_VISIBLE_MODELS["claude"] + assert set(codex_ids) == DEFAULT_VISIBLE_MODELS["codex"] # Claude rows carry a tier derived from the id. by_id = {m["id"]: m for m in body["claude"]} assert by_id["claude-opus-4-8"]["name"] == "Opus 4.8" assert by_id["claude-opus-4-8"]["tier"] == "opus" assert by_id["claude-sonnet-4-6"]["tier"] == "sonnet" - assert by_id["claude-haiku-4-5-20251001"]["tier"] == "haiku" # Codex rows intentionally omit `tier` — the field doesn't apply. for row in body["codex"]: assert "tier" not in row diff --git a/backend/tests/test_client_error.py b/backend/tests/test_client_error.py index 8818bae5a..2764f1a81 100644 --- a/backend/tests/test_client_error.py +++ b/backend/tests/test_client_error.py @@ -88,6 +88,36 @@ def test_oversized_message_and_stack_are_truncated(client, owner_token): assert len(errs[0].get("stack", "")) <= 8000 +def test_client_error_scrubs_every_field_before_activity_retention(client, owner_token): + app_id = _make_app(client, owner_token) + token = _app_token(client, owner_token, app_id) + secrets = { + "message": "MESSAGE-SECRET", + "where": "WHERE-SECRET", + "stack": "STACK-SECRET", + "url": "URL-SECRET", + } + r = client.post( + "/api/client-error", + json={ + "message": f"boom?token={secrets['message']}", + "where": f"app:window.onerror?token={secrets['where']}", + "stack": f"at render (https://app.test/?token={secrets['stack']})", + "url": f"https://mobius.test/shell/?token={secrets['url']}", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 204, r.text + + errs = [e for e in _activity_lines() if e.get("ev") == "app_error"] + assert len(errs) == 1 + retained = json.dumps(errs[0]) + for secret in secrets.values(): + assert secret not in retained + for field in ("message", "where", "stack", "url"): + assert "[redacted]" in errs[0][field] + + def test_owner_shell_error_records_no_app_id(client, owner_token): # An error reported with the owner JWT (the shell, not an app iframe) # must NOT carry an app_id, or it would pollute a real app's last_5_errors. diff --git a/backend/tests/test_frame_error_reporting.py b/backend/tests/test_frame_error_reporting.py index 8e701bd67..1cb609618 100644 --- a/backend/tests/test_frame_error_reporting.py +++ b/backend/tests/test_frame_error_reporting.py @@ -1,5 +1,6 @@ """Mini-app frame failures expose a safe, actionable recovery report.""" +import re from pathlib import Path @@ -12,5 +13,20 @@ def test_frame_error_report_redacts_module_token(): assert "function redactErrorCredentials" in frame assert "detail = redactErrorCredentials(detail)" in frame assert "'$1[redacted]'" in frame + assert re.search( + r"function handleFrameError\(title, detail, source\)\s*\{\s*" + r"[\s\S]{0,260}?detail = redactErrorCredentials\(detail\)" + r"[\s\S]{0,120}?if \(window\.__frameMounted\)", + frame, + ), "runtime error details must be redacted before any console branch" + report_block = frame.split("function reportAppError", 1)[1].split( + "window.addEventListener('error'", 1 + )[0] + assert "const safeMessage = redactErrorCredentials(message)" in report_block + assert "const safeStack = stack ? redactErrorCredentials(stack)" in report_block + assert "const safeUrl = redactErrorCredentials(location.href)" in report_block + assert "message: safeMessage.slice" in report_block + assert "stack: safeStack ? safeStack.slice" in report_block + assert frame.count("e.preventDefault()") >= 2 assert "user-select: text" in frame assert "Report to agent" in frame diff --git a/backend/tests/test_github_routes.py b/backend/tests/test_github_routes.py index 293aee143..135e4c074 100644 --- a/backend/tests/test_github_routes.py +++ b/backend/tests/test_github_routes.py @@ -748,6 +748,97 @@ def test_graphql_mutation_as_string_literal_allowed(client, auth, monkeypatch): # --- contribution submit (approval button path) ----------------------- +def test_reviewed_pr_labels_are_bounded_to_the_visible_two(): + assert github_routes._reviewed_pr_labels({ + "labels": [" bug ", "area: ui", "hidden-third"], + }) == ["bug", "area: ui"] + assert github_routes._reviewed_pr_labels({ + "labels": ["bug", "BUG", "area: ui"], + }) == ["bug"] + assert github_routes._reviewed_pr_labels({ + "labels": [None, "", "bug", "area: ui", "hidden-third"], + }) == ["bug", "area: ui"] + assert github_routes._reviewed_pr_labels({"labels": "bug"}) == [] + + +def test_pr_labels_apply_only_existing_names_and_preserve_missing( + monkeypatch, tmp_path, +): + calls = [] + + def fake_gh(repo, *args, check=True): + calls.append(args) + if "--paginate" in args: + return _cp("bug\narea: ui\n") + return _cp("[]") + + monkeypatch.setattr(github_routes, "_gh", fake_gh) + patch = github_routes._apply_reviewed_pr_labels( + tmp_path, + "mobius-os/mobius", + 123, + ["Bug", "area: backend"], + ) + + assert patch["last_submit_labels_requested"] == ["Bug", "area: backend"] + assert patch["last_submit_labels_applied"] == ["bug"] + assert patch["last_submit_labels_missing"] == ["area: backend"] + assert "Some reviewed labels" in patch["last_submit_labels_note"] + apply_call = calls[-1] + assert apply_call[:3] == ("api", "--method", "POST") + assert "labels[]=bug" in apply_call + assert "labels[]=area: backend" not in apply_call + + +def test_pr_label_permission_failure_does_not_fail_an_open_pr( + monkeypatch, tmp_path, +): + def fake_gh(repo, *args, check=True): + if "--paginate" in args: + return _cp("bug\n") + return _cp("forbidden", returncode=1) + + monkeypatch.setattr(github_routes, "_gh", fake_gh) + patch = github_routes._apply_reviewed_pr_labels( + tmp_path, + "someone/example", + 7, + ["bug"], + ) + + assert patch["last_submit_labels_applied"] == [] + assert "did not confirm" in patch["last_submit_labels_note"] + + +@pytest.mark.parametrize( + "label_failure", + [ + subprocess.TimeoutExpired(["gh", "api"], timeout=30), + OSError("gh could not start"), + ], + ids=["apply-timeout", "apply-launch-error"], +) +def test_pr_label_apply_transport_failure_is_nonfatal( + monkeypatch, tmp_path, label_failure, +): + def fake_gh(repo, *args, check=True): + if "--paginate" in args: + return _cp("bug\n") + raise label_failure + + monkeypatch.setattr(github_routes, "_gh", fake_gh) + patch = github_routes._apply_reviewed_pr_labels( + tmp_path, + "someone/example", + 7, + ["bug"], + ) + + assert patch["last_submit_labels_requested"] == ["bug"] + assert patch["last_submit_labels_applied"] == [] + assert "pull request is open" in patch["last_submit_labels_note"] + + def _write_contribution(app_id, record_id, record, diff_text=""): base = Path(get_settings().data_dir) / "apps" / str(app_id) / "contributions" base.mkdir(parents=True, exist_ok=True) @@ -1708,12 +1799,21 @@ def _commit_metadata( ) -def test_submit_contribution_creates_review_ready_pr_from_prepared_record( - client, owner_token, monkeypatch, +@pytest.mark.parametrize( + "failure_kind", + ["timeout", "launch-error"], +) +def test_submit_contribution_keeps_accepted_pr_open_on_label_transport_failure( + client, owner_token, monkeypatch, failure_kind, ): + label_failure = ( + subprocess.TimeoutExpired(["gh", "api"], timeout=30) + if failure_kind == "timeout" + else OSError("gh could not start") + ) _write_token(login="octocat") app_id, app_token = _app_token(client, owner_token, github_access=True) - record_id = "rec-pr-1" + record_id = f"rec-pr-label-{failure_kind}" repo = Path(get_settings().data_dir) / "contributions" / record_id / "repo" (repo / ".git").mkdir(parents=True) diff_text = "diff --git a/index.jsx b/index.jsx\n+hello\n" @@ -1738,6 +1838,7 @@ def test_submit_contribution_creates_review_ready_pr_from_prepared_record( "base_sha": base, "head_sha": head, "diff_sha256": hashlib.sha256(diff_text.encode()).hexdigest(), + "labels": ["bug"], }, } _write_contribution(app_id, record_id, record, diff_text) @@ -1803,6 +1904,8 @@ def fake_gh(repo_path, *args, check=True): return _cp("[]") if args[:2] == ("pr", "create"): return _cp("https://github.com/mobius-os/app-demo/pull/42\n") + if args[:2] == ("api", "--paginate"): + raise label_failure return _cp("") monkeypatch.setattr("app.routes.github._git", fake_git) @@ -1818,11 +1921,15 @@ def fake_gh(repo_path, *args, check=True): assert body["number"] == 42 assert body["record"]["status"] == "open" assert body["record"]["url"] == body["url"] + assert body["record"]["last_submit_labels_requested"] == ["bug"] + assert body["record"]["last_submit_labels_applied"] == [] + assert "pull request is open" in body["record"]["last_submit_labels_note"] assert ("repo", "fork", "--remote", "--remote-name", "fork") in gh_calls assert not any(call[:2] == ("remote", "set-url") for call in git_calls) create_call = next(call for call in gh_calls if call[:2] == ("pr", "create")) assert "--draft" not in create_call assert "octocat:fix/demo-polish" in create_call + assert create_call[-2:] == ("--base", "main") assert ("push", "fork", "HEAD:refs/heads/fix/demo-polish") in git_calls assert sum(call[:1] == ("fetch",) for call in git_calls) == 1 assert not any(call[:2] == ("pr", "list") for call in gh_calls) @@ -1837,6 +1944,154 @@ def fake_gh(repo_path, *args, check=True): assert stored["status"] == "open" assert stored["number"] == 42 assert stored["head_repository"] == "octocat/app-demo-1" + assert stored["last_submit_labels_requested"] == ["bug"] + assert stored["last_submit_labels_applied"] == [] + assert stored["last_submit_labels_note"] == body["record"]["last_submit_labels_note"] + + +@pytest.mark.parametrize( + ("failure_kind", "existing_mode"), + [ + ("timeout", "match"), + ("launch-error", "match"), + ("timeout", "absent"), + ("launch-error", "absent"), + ("timeout", "wrong-head"), + ], +) +def test_submit_contribution_recovers_ambiguous_create_by_exact_pushed_head( + client, owner_token, monkeypatch, failure_kind, existing_mode, +): + """A lost create response probes once and never creates a second PR.""" + create_failure = ( + subprocess.TimeoutExpired(["gh", "pr", "create"], timeout=30) + if failure_kind == "timeout" + else OSError("gh could not start") + ) + _write_token(login="octocat") + app_id, app_token = _app_token(client, owner_token, github_access=True) + record_id = f"rec-pr-create-{failure_kind}-{existing_mode}" + repo = Path(get_settings().data_dir) / "contributions" / record_id / "repo" + (repo / ".git").mkdir(parents=True) + diff_text = "diff --git a/index.jsx b/index.jsx\n+hello\n" + base = "b" * 40 + head = "a" * 40 + record = { + "id": record_id, + "type": "pr", + "repo": "mobius-os/app-demo", + "status": "prepared", + "title": "Polish demo", + "branch": "fix/demo-polish", + "created_at": "2026-07-09T00:00:00Z", + "updated_at": "2026-07-09T00:00:00Z", + "plan": { + "action": "pr", + "repo": "mobius-os/app-demo", + "title": "Polish demo", + "body_draft": "## What\n\nPolishes the demo.", + "branch": "fix/demo-polish", + "repo_path": str(repo), + "base_sha": base, + "head_sha": head, + "diff_sha256": hashlib.sha256(diff_text.encode()).hexdigest(), + "labels": ["bug"], + }, + } + _write_contribution(app_id, record_id, record, diff_text) + + monkeypatch.setattr("app.routes.github.shutil.which", lambda name: f"/bin/{name}") + monkeypatch.setattr( + "app.routes.github._assert_fresh", + lambda *_args, **_kwargs: (base, head, record["plan"]["diff_sha256"]), + ) + monkeypatch.setattr("app.routes.github._assert_coauthor_trailer", lambda *_args: None) + monkeypatch.setattr("app.routes.github._assert_clean_worktree", lambda *_args: None) + monkeypatch.setattr( + "app.routes.github._normalize_head_attribution", + lambda *_args, **_kwargs: {}, + ) + monkeypatch.setattr( + "app.routes.github._assert_merges_with_upstream", + lambda *_args, **_kwargs: { + "last_submit_upstream_branch": "main", + "last_submit_upstream_sha": base, + }, + ) + monkeypatch.setattr( + "app.routes.github._ensure_owner_fork_remote", + lambda *_args, **_kwargs: "octocat/app-demo-1", + ) + monkeypatch.setattr( + "app.routes.github._push_reviewed_topic", + lambda *_args, **kwargs: ("HEAD", kwargs["record_patch"]), + ) + + git_calls = [] + + def fake_git(repo_path, *args, check=True): + git_calls.append(args) + if args == ("rev-parse", "--abbrev-ref", "HEAD"): + return _cp("develop\n") + if args == ("rev-parse", "HEAD"): + return _cp(head + "\n") + return _cp("") + + gh_calls = [] + + def fake_gh(repo_path, *args, check=True): + gh_calls.append(args) + if args[:2] == ("pr", "create"): + raise create_failure + if args[:2] == ("pr", "list"): + if existing_mode == "absent": + return _cp("[]") + found_head = head if existing_mode == "match" else "c" * 40 + return _cp(json.dumps([{ + "url": "https://github.com/mobius-os/app-demo/pull/42", + "headRefOid": found_head, + }])) + if args[:2] == ("api", "--paginate"): + return _cp("bug\n") + if args[:3] == ("api", "--method", "POST"): + return _cp("[]") + return _cp("") + + monkeypatch.setattr("app.routes.github._git", fake_git) + monkeypatch.setattr("app.routes.github._gh", fake_gh) + + response = client.post( + f"/api/github/contributions/{app_id}/{record_id}/submit", + headers={"Authorization": f"Bearer {app_token}"}, + ) + + creates = [call for call in gh_calls if call[:2] == ("pr", "create")] + probes = [call for call in gh_calls if call[:2] == ("pr", "list")] + assert len(creates) == 1, "an ambiguous response must never trigger a second create" + assert len(probes) == 1 + assert creates[0][-2:] == ("--base", "main") + assert "url,headRefOid" in probes[0] + assert "octocat:fix/demo-polish" in probes[0] + assert probes[0][probes[0].index("--base") + 1] == "main" + assert ("checkout", "-q", "develop") in git_calls + + stored = json.loads( + (Path(get_settings().data_dir) / "apps" / str(app_id) / + "contributions" / f"{record_id}.json").read_text() + ) + if existing_mode == "match": + assert response.status_code == 200, response.text + assert response.json()["url"].endswith("/pull/42") + assert stored["status"] == "open" + assert stored["url"].endswith("/pull/42") + assert stored["last_submit_push_sha"] == head + assert stored["last_submit_labels_applied"] == ["bug"] + else: + assert response.status_code == 409, response.text + assert stored["status"] == "prepared" + assert stored["last_submit_stage"] == "pushed" + assert stored["last_submit_push_sha"] == head + assert "url" not in stored def test_submit_contribution_normalizes_fallback_author_before_push( diff --git a/backend/tests/test_model_registry.py b/backend/tests/test_model_registry.py index 33bb720d2..657bc381f 100644 --- a/backend/tests/test_model_registry.py +++ b/backend/tests/test_model_registry.py @@ -48,6 +48,8 @@ def test_known_models_fallback_lists_current_claude_and_codex(): # asserting them by name catches a wrong date suffix that a startswith # check would miss. for model_id in ( + "claude-fable-5", + "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", @@ -58,7 +60,13 @@ def test_known_models_fallback_lists_current_claude_and_codex(): "claude-haiku-4-5-20251001", ): assert model_id in claude, f"{model_id} missing from KNOWN_MODELS[claude]" - assert claude[0] == "claude-opus-4-8", "Opus 4.8 must be the default" + assert claude[:4] == [ + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-opus-4-7", + ] + assert providers.DEFAULT_MODELS["claude"] == "claude-opus-4-8" # Current Codex family — each canonical id present by name. for model_id in ( "gpt-5.6-sol", @@ -100,6 +108,24 @@ def test_recovery_models_match_platform_fallback_registry(): assert chat_runner.RECOVERY_MODELS == expected +def test_default_model_visibility_is_curated_until_owner_saves_preferences(): + hidden = set(providers.hidden_model_ids(None)) + for model_id in ( + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + ): + assert model_id not in hidden + assert "claude-opus-4-7" in hidden + assert "gpt-5.4" in hidden + assert providers.hidden_model_ids({"hidden_ids": []}) == [] + + def test_fallback_models_shape_matches_registry_entries(): """`_fallback_models` returns the same {id,label,provider,available} shape the live path produces, so the picker renders identically whether diff --git a/backend/tests/test_platform_update.py b/backend/tests/test_platform_update.py index ca027f0c3..eeb9c70fd 100644 --- a/backend/tests/test_platform_update.py +++ b/backend/tests/test_platform_update.py @@ -19,7 +19,7 @@ import subprocess import textwrap -from contextlib import nullcontext +from contextlib import contextmanager, nullcontext from pathlib import Path from types import SimpleNamespace @@ -473,13 +473,18 @@ async def test_apply_rebuilds_frontend_but_no_restart_when_update_is_frontend_on pu.SERVING_SHA_FILE.write_text(served + "\n") # the running uvicorn's sha new = _local_commit(platform, edits={"frontend/src/App.jsx": "export default 2\n"}) calls = [] + hook_calls = [] async def fake_rebuild(repo, res): calls.append((repo, res.new_sha)) monkeypatch.setattr(pu, "_reconcile_under_lock", lambda repo, at_boot: ( - pu.ReconcileResult("updated", served, new, new) + pu.ReconcileResult("updated", served, new, new, hook_source_sha=new) )) + monkeypatch.setattr( + pu, "_refresh_git_hooks", + lambda repo, source_oid: hook_calls.append((repo, source_oid)) or "", + ) monkeypatch.setattr(pu, "_rebuild_frontend_after_update_if_needed", fake_rebuild) res = await pu.apply_platform_update(SimpleNamespace(), platform) @@ -489,6 +494,241 @@ async def fake_rebuild(repo, res): assert res["state"] == pu.PlatformUpdateState.UP_TO_DATE.value assert res["needs_restart"] is False assert calls == [(platform, new)] + assert hook_calls == [(platform, new)] + + +def test_boot_reconcile_refreshes_copied_hooks(monkeypatch, tmp_path): + platform = tmp_path / "platform" + platform.mkdir() + calls = [] + monkeypatch.setattr(pu, "PLATFORM_REPO", platform) + monkeypatch.setattr(pu, "_reconcile_under_lock", lambda repo, at_boot: ( + pu.ReconcileResult( + "up_to_date", "pre-sha", "pre-sha", "target-sha", + hook_source_sha="trusted-hook-sha", + ) + )) + monkeypatch.setattr( + pu, "_refresh_git_hooks", + lambda repo, source_oid: calls.append((repo, source_oid)) or "", + ) + + summary = pu.reconcile_clone_sync() + + assert calls == [(platform, "trusted-hook-sha")] + assert "hooks=refreshed" in summary + + +def test_reconcile_pins_upstream_hook_source_before_unlock(monkeypatch, tmp_path): + repo = tmp_path / "platform" + repo.mkdir() + events = [] + + @contextmanager + def fake_lock(): + events.append("locked") + yield + events.append("unlocked") + + def fake_reconcile(repo_path, *, at_boot): + assert events == ["locked"] + assert repo_path == repo + assert at_boot is True + return pu.ReconcileResult("up_to_date", "pre", "pre", "target") + + def fake_rev(repo_path, ref): + assert events == ["locked"] + assert repo_path == repo + assert ref == pu.UPSTREAM_BRANCH + return "trusted-upstream-oid" + + monkeypatch.setattr(pu, "_reconcile_flock", fake_lock) + monkeypatch.setattr(pu, "reconcile_clone", fake_reconcile) + monkeypatch.setattr(pu, "_rev", fake_rev) + + result = pu._reconcile_under_lock(repo, at_boot=True) + + assert events == ["locked", "unlocked"] + assert result.hook_source_sha == "trusted-upstream-oid" + + +def _make_hook_repo(tmp_path: Path, *, complete: bool = True) -> Path: + tmp_path.mkdir(parents=True, exist_ok=True) + repo = tmp_path / "hook-repo" + _git(tmp_path, "init", "-b", "main", str(repo)) + scripts = repo / "scripts" + (scripts / "githooks").mkdir(parents=True) + (scripts / "install-hooks.sh").write_text("#!/bin/sh\nexit 99\n") + (scripts / "pre-commit.sh").write_text("#!/bin/sh\necho committed-pre-commit\n") + if complete: + (scripts / "githooks" / "pre-push").write_text( + "#!/bin/sh\necho committed-pre-push\n" + ) + _git(repo, "add", "scripts") + _git(repo, "commit", "-q", "-m", "add hooks") + return repo + + +def test_hook_refresh_uses_only_committed_allowlisted_sources(tmp_path): + repo = _make_hook_repo(tmp_path) + source_oid = _git(repo, "rev-parse", "HEAD").stdout.strip() + # Neither a dirty managed hook nor a newly dropped executable may run merely + # because a healthy boot refreshes the installed copies. + (repo / "scripts" / "pre-commit.sh").write_text("#!/bin/sh\necho DIRTY\n") + (repo / "scripts" / "githooks" / "post-checkout").write_text( + "#!/bin/sh\necho UNTRACKED\n" + ) + + assert pu._refresh_git_hooks(repo, source_oid) == "" + + hooks = repo / ".git" / "hooks" + assert (hooks / "pre-commit").read_text() == ( + "#!/bin/sh\necho committed-pre-commit\n" + ) + assert (hooks / "pre-push").read_text() == ( + "#!/bin/sh\necho committed-pre-push\n" + ) + assert not (hooks / "post-checkout").exists() + assert (hooks / "pre-commit").stat().st_mode & 0o777 == 0o755 + assert (hooks / "pre-push").stat().st_mode & 0o777 == 0o755 + configured = _git(repo, "config", "--local", "--get", "core.hooksPath") + assert Path(configured.stdout.strip()) == hooks.resolve() + + +def test_hook_refresh_reads_one_pinned_generation_when_head_moves( + tmp_path, monkeypatch, +): + repo = _make_hook_repo(tmp_path) + source_oid = _git(repo, "rev-parse", "HEAD").stdout.strip() + expected = { + "pre-commit": b"#!/bin/sh\necho committed-pre-commit\n", + "pre-push": b"#!/bin/sh\necho committed-pre-push\n", + } + + (repo / "scripts" / "pre-commit.sh").write_text("#!/bin/sh\necho NEW-commit\n") + (repo / "scripts" / "githooks" / "pre-push").write_text( + "#!/bin/sh\necho NEW-push\n" + ) + _git(repo, "add", "scripts") + _git(repo, "commit", "-q", "-m", "new hook generation") + next_oid = _git(repo, "rev-parse", "HEAD").stdout.strip() + _git(repo, "reset", "--hard", "-q", source_oid) + + real_hook_git = pu._hook_git + moved = False + + def move_head_between_blob_reads(repo_path, *args): + nonlocal moved + result = real_hook_git(repo_path, *args) + if ( + not moved + and args == ( + "cat-file", "blob", f"{source_oid}:scripts/pre-commit.sh", + ) + ): + moved = True + _git(repo, "reset", "--hard", "-q", next_oid) + return result + + monkeypatch.setattr(pu, "_hook_git", move_head_between_blob_reads) + + assert pu._refresh_git_hooks(repo, source_oid) == "" + assert moved is True + hooks = repo / ".git" / "hooks" + assert { + name: (hooks / name).read_bytes() + for name in expected + } == expected + + +def test_hook_refresh_rolls_back_without_absent_destinations( + tmp_path, monkeypatch, +): + repo = _make_hook_repo(tmp_path) + source_oid = _git(repo, "rev-parse", "HEAD").stdout.strip() + assert pu._refresh_git_hooks(repo, source_oid) == "" + hooks = repo / ".git" / "hooks" + old = { + name: (hooks / name).read_bytes() + for name in ("pre-commit", "pre-push") + } + (repo / "scripts" / "pre-commit.sh").write_text("#!/bin/sh\necho new-commit\n") + (repo / "scripts" / "githooks" / "pre-push").write_text( + "#!/bin/sh\necho new-push\n" + ) + _git(repo, "add", "scripts") + _git(repo, "commit", "-q", "-m", "update hooks") + source_oid = _git(repo, "rev-parse", "HEAD").stdout.strip() + + real_replace = pu.os.replace + failed = False + + def fail_second_hook_once(source, destination): + nonlocal failed + target = Path(destination) + if target.name in old: + assert all((hooks / name).exists() for name in old) + if target.name == "pre-push" and not failed: + failed = True + raise OSError("simulated second replacement failure") + real_replace(source, destination) + if target.name in old: + assert all((hooks / name).exists() for name in old) + + monkeypatch.setattr(pu.os, "replace", fail_second_hook_once) + + result = pu._refresh_git_hooks(repo, source_oid) + + assert "simulated second replacement failure" in result + assert {(name, (hooks / name).read_bytes()) for name in old} == set(old.items()) + + +def test_hook_refresh_missing_incomplete_and_timeout_are_nonfatal( + tmp_path, monkeypatch, +): + missing = tmp_path / "missing" + _git(tmp_path, "init", "-b", "main", str(missing)) + (missing / "README").write_text("old checkout\n") + _git(missing, "add", "README") + _git(missing, "commit", "-q", "-m", "old checkout") + missing_oid = _git(missing, "rev-parse", "HEAD").stdout.strip() + assert pu._refresh_git_hooks(missing, missing_oid) is None + + incomplete = _make_hook_repo(tmp_path / "incomplete", complete=False) + incomplete_oid = _git(incomplete, "rev-parse", "HEAD").stdout.strip() + result = pu._refresh_git_hooks(incomplete, incomplete_oid) + assert result + assert "pre-push" in result + + monkeypatch.setattr( + pu, "_refresh_git_hooks_impl", + lambda _repo, _source_oid: (_ for _ in ()).throw( + subprocess.TimeoutExpired(["git", "show"], timeout=15) + ), + ) + assert "TimeoutExpired" in pu._refresh_git_hooks(missing, missing_oid) + + +def test_hook_refresh_config_failure_keeps_complete_first_population( + tmp_path, monkeypatch, +): + repo = _make_hook_repo(tmp_path) + source_oid = _git(repo, "rev-parse", "HEAD").stdout.strip() + real_hook_git = pu._hook_git + + def fail_config(repo_path, *args): + if args[:3] == ("config", "--local", "core.hooksPath"): + return subprocess.CompletedProcess(args, 1, b"", b"config locked") + return real_hook_git(repo_path, *args) + + monkeypatch.setattr(pu, "_hook_git", fail_config) + + result = pu._refresh_git_hooks(repo, source_oid) + + assert "config locked" in result + hooks = repo / ".git" / "hooks" + assert (hooks / "pre-commit").read_text().startswith("#!/bin/sh") + assert (hooks / "pre-push").read_text().startswith("#!/bin/sh") @pytest.mark.asyncio diff --git a/backend/tests/test_reflection_brief_template.py b/backend/tests/test_reflection_brief_template.py new file mode 100644 index 000000000..0677df7e9 --- /dev/null +++ b/backend/tests/test_reflection_brief_template.py @@ -0,0 +1,16 @@ +"""The baked Reflection scaffold asks only questions earned by a real run.""" + +from pathlib import Path + + +def test_default_reflection_brief_has_no_placeholder_question_carrier(): + template = ( + Path(__file__).resolve().parents[1] + / "scripts" + / "reflection-brief-template.html" + ).read_text(encoding="utf-8") + + assert "intentionally absent by default" in template + assert "data-report-questions" not in template + assert "{{QUESTION_" not in template + assert "{{INPUT_" not in template diff --git a/backend/tests/test_reflection_runner.py b/backend/tests/test_reflection_runner.py index 642f0d271..972672972 100644 --- a/backend/tests/test_reflection_runner.py +++ b/backend/tests/test_reflection_runner.py @@ -1084,3 +1084,17 @@ def test_seed_skill_uses_chat_sent_without_false_activity_substitutes(): assert "never infer activity from `Chat.created_at`" in seed assert "a shared timestamp alone is not evidence" in seed assert "this schema has no `chat_sent`" not in seed + + +def test_seed_skill_aligns_question_engagement_and_owns_brief_style(): + seed = ( + Path(dr.__file__).resolve().parent / "seed-skills" / "reflection.md" + ).read_text(encoding="utf-8") + assert "Question-engagement evidence must be report-aligned" in seed + assert "valid, non-empty" in seed + assert "same `report_date`" in seed + assert "empty questions array means the run asked nothing" in seed + assert "weak channel signal, never a durable partner preference" in seed + assert "Adapt the brief instead of obeying a fixed style control" in seed + assert "There is no\n`verbosity`, `focus`, or `avoid` setting to honor" in seed + assert "Honor the brief-style setting" not in seed diff --git a/backend/tests/test_runtime_libs.py b/backend/tests/test_runtime_libs.py index 17690a054..cbda06c26 100644 --- a/backend/tests/test_runtime_libs.py +++ b/backend/tests/test_runtime_libs.py @@ -21,6 +21,7 @@ mobius_runtime_path, runtime_library_aliases, runtime_inject_path, + runtime_node_path, ) @@ -121,6 +122,50 @@ def test_app_local_or_transitive_react_cannot_shadow_platform_runtime(tmp_path): assert "shadow-react-copy" not in output.read_text() +def test_three_addons_resolve_from_the_pinned_runtime(tmp_path): + """Documented addons imports must survive package-root runtime pinning.""" + aliases = dict(runtime_library_aliases()) + assert aliases["three"] == runtime_node_path() / "three" + assert ( + aliases["three/addons"] + == runtime_node_path() / "three" / "examples" / "jsm" + ) + + entry = tmp_path / "three-addons.jsx" + output = tmp_path / "three-addons.js" + metafile = tmp_path / "three-addons-meta.json" + entry.write_text( + """import { OrbitControls } from 'three/addons/controls/OrbitControls.js' +import { STLLoader } from 'three/addons/loaders/STLLoader.js' + +export default function ThreeAddonsFixture() { + return [OrbitControls.name, STLLoader.name] +} +""" + ) + + completed = subprocess.run( + esbuild_command(entry, output, metafile=metafile), + capture_output=True, + check=False, + env=esbuild_environment(), + text=True, + timeout=ESBUILD_TIMEOUT_SECS, + ) + assert completed.returncode == 0, completed.stderr + + metadata = json.loads(metafile.read_text()) + entry_outputs = [ + details for details in metadata["outputs"].values() + if details.get("entryPoint") + ] + assert len(entry_outputs) == 1 + assert entry_outputs[0].get("imports") == [], ( + "Three addons escaped the pinned self-contained app bundle" + ) + assert output.is_file() and output.stat().st_size > 0 + + def test_app_hosts_have_no_runtime_import_map_or_static_module_imports(): frame = FRAME.read_text() standalone = STANDALONE.read_text() diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 7967de782..bdab40055 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -269,7 +269,7 @@ def test_get_settings_returns_background_agent_defaults(client, auth): assert body["agent_settings"]["model"] is None assert body["agent_settings"]["effort"] == "medium" assert body["background_agents"]["primary"]["provider"] == "codex" - assert body["background_agents"]["primary"]["model"] is None + assert body["background_agents"]["primary"]["model"] == "gpt-5.6-terra" assert body["background_agents"]["primary"]["effort"] == "medium" assert body["background_agents"]["fallback"] is None @@ -285,7 +285,7 @@ def test_background_agent_defaults_do_not_inherit_chat_model_defaults(tmp_path): background = providers.background_agent_settings(str(tmp_path), "codex") assert background["primary"] == { "provider": "codex", - "model": providers.DEFAULT_MODELS["codex"], + "model": providers.DEFAULT_BACKGROUND_MODELS["codex"], "effort": "medium", } assert background["fallback"] is None @@ -669,7 +669,7 @@ def test_background_agent_settings_drops_cross_provider_models(tmp_path): } assert background["fallback"] == { "provider": "codex", - "model": providers.DEFAULT_MODELS["codex"], + "model": providers.DEFAULT_BACKGROUND_MODELS["codex"], "effort": "medium", } @@ -821,11 +821,34 @@ def forbidden_exec(*a, **k): asyncio.run(providers._fetch_codex_models(str(tmp_path))) -def test_model_prefs_default_empty(client, auth): - """A fresh owner has no hidden models.""" +def test_model_prefs_default_is_curated(client, auth): + """A fresh owner starts with the compact recommended model set.""" + from app import providers res = client.get("/api/owner/model-prefs", headers=auth) assert res.status_code == 200 + assert res.json() == {"hidden_ids": providers.hidden_model_ids(None)} + + +def test_model_prefs_explicit_empty_is_distinct_from_missing(client, auth, db): + """Saving an empty hidden list opts into showing the whole registry.""" + from app import models, providers + + owner = db.query(models.Owner).first() + assert owner.model_prefs_json is None + assert providers.hidden_model_ids(owner.model_prefs_json) + + res = client.patch( + "/api/owner/model-prefs", + json={"hidden_ids": []}, + headers=auth, + ) + assert res.status_code == 200 assert res.json() == {"hidden_ids": []} + db.refresh(owner) + assert owner.model_prefs_json == {"hidden_ids": []} + assert client.get("/api/owner/model-prefs", headers=auth).json() == { + "hidden_ids": [], + } def test_model_prefs_roundtrip_dedupes(client, auth, db): @@ -899,26 +922,38 @@ def test_model_prefs_clear(client, auth, db): assert owner.model_prefs_json == {"hidden_ids": []} -def test_live_model_entries_use_live_sdk_order_only(): - """A successful live fetch uses provider SDK/CLI order and does not - mix in stale fallback rows.""" +def test_live_model_entries_keep_curated_aliases_plus_live_extras(): + """The requested compatibility aliases survive a sparse live catalog.""" from app.providers import _live_model_entries merged = _live_model_entries( "claude", ["claude-future-model", "claude-opus-4-8"], ) - assert merged == [ - { - "id": "claude-future-model", "label": "claude-future-model", - "provider": "claude", "available": True, - }, - { - "id": "claude-opus-4-8", "label": "Opus 4.8", - "provider": "claude", "available": True, - }, + assert [row["id"] for row in merged] == [ + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-future-model", ] assert "claude-haiku-4-5-20251001" not in [m["id"] for m in merged] +def test_live_model_entries_float_curated_defaults_in_requested_order(): + from app import providers + + entries = providers._live_model_entries( + "claude", + ["claude-sonnet-5", "claude-future-model", "claude-fable-5", "claude-opus-4-8"], + ) + assert [entry["id"] for entry in entries] == [ + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-future-model", + ] + + def test_resolve_displayed_models_keeps_selected_even_when_hidden(): """The picker's filter MUST keep the currently-selected model visible even when it appears in hidden_ids. The codex-review spec diff --git a/frontend/public/app-frame.html b/frontend/public/app-frame.html index d7df72019..b934f914d 100644 --- a/frontend/public/app-frame.html +++ b/frontend/public/app-frame.html @@ -494,6 +494,10 @@ return EXTENSION_ORIGIN_RE.test(String(source || '')) || EXTENSION_ORIGIN_RE.test(String(stack || '')) } function handleFrameError(title, detail, source) { + // Redact before ANY branch can log, display or retain the detail. showErr + // repeats this at its direct call boundary for load failures that do not + // pass through the global error listeners. + detail = redactErrorCredentials(detail) if (window.__frameMounted) { // The browser also logs the uncaught error natively; this line // records why no error panel replaced the working UI. @@ -509,9 +513,13 @@ } window.addEventListener('error', function(e) { handleFrameError('Runtime error', e.error ? e.error.stack || e.message : e.message, e.filename) + // The browser's native console path would otherwise print the original + // token-bearing Error after our redacted replacement/panel. + e.preventDefault() }) window.addEventListener('unhandledrejection', function(e) { handleFrameError('Unhandled error', e.reason ? (e.reason.stack || String(e.reason)) : String(e)) + e.preventDefault() }) @@ -984,17 +992,21 @@ let _reportToken = null; const _reportSeen = new Map(); function reportAppError(where, message, stack) { - if (!_reportToken || !message) return; - const key = String(message).slice(0, 200); + const safeMessage = redactErrorCredentials(message); + const safeStack = stack ? redactErrorCredentials(stack) : undefined; + const safeWhere = where ? redactErrorCredentials(where) : undefined; + const safeUrl = redactErrorCredentials(location.href); + if (!_reportToken || !safeMessage) return; + const key = safeMessage.slice(0, 200); const now = Date.now(); const last = _reportSeen.get(key); if (last && now - last < 60000) return; _reportSeen.set(key, now); const body = JSON.stringify({ - message: String(message).slice(0, 2000), - where: where, - stack: stack ? String(stack).slice(0, 8000) : undefined, - url: location.href, + message: safeMessage.slice(0, 2000), + where: safeWhere, + stack: safeStack ? safeStack.slice(0, 8000) : undefined, + url: safeUrl, }); runtimeToken().then(function (token) { return fetch('/api/client-error', { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index d087b37bb..974ba296a 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -358,6 +358,10 @@ export const api = { }, apps: { list: () => apiFetch('/apps/'), + markActivitySeen: (appId, activityVersion) => apiFetch(`/apps/${appId}/activity/seen`, { + method: 'POST', + body: JSON.stringify({ activity_version: activityVersion }), + }), remove: (appId) => apiFetch(`/apps/${appId}`, { method: 'DELETE' }), recover: (appId) => apiFetch(`/apps/${appId}/recover`, { method: 'POST' }), // Wipes the app's runtime storage back to empty while KEEPING it diff --git a/frontend/src/components/ChatView/ChatInputBar.jsx b/frontend/src/components/ChatView/ChatInputBar.jsx index bd26aad29..d71328de5 100644 --- a/frontend/src/components/ChatView/ChatInputBar.jsx +++ b/frontend/src/components/ChatView/ChatInputBar.jsx @@ -22,7 +22,7 @@ * ║ CONTRACTS — small but load-bearing ║ * ║ ║ * ║ 1. AUTOSIZE THRESHOLD ║ - * ║ `handleTextareaChange` toggles `chat__pill--tall` when ║ + * ║ Shared textarea sizing toggles `chat__pill--tall` when ║ * ║ height > 45px. NOT 30 (single-line is ~31, fires every ║ * ║ keystroke), NOT 50 (lags two-line typing). 45 sits ║ * ║ safely between single-line and two-line. See ChatView.css ║ @@ -62,9 +62,10 @@ * ║ `_isTouchPrimary` is detected once via ║ * ║ `matchMedia('(hover: none) and (pointer: coarse)')` and ║ * ║ gates plain Enter. Touch devices: Enter inserts a ║ - * ║ newline. Desktop: Enter sends or steers. Cmd/Ctrl+Enter ║ - * ║ is an explicit hardware-keyboard shortcut for the same ║ - * ║ send/steer action. Shift+Enter always inserts a newline. ║ + * ║ newline. Desktop: Enter sends or steers queued text. ║ + * ║ Cmd/Ctrl+Enter fast-forwards composed text into a live ║ + * ║ turn when possible, otherwise it sends normally. ║ + * ║ Shift+Enter always inserts a newline. ║ * ║ ║ * ╚══════════════════════════════════════════════════════════════════╝ */ @@ -322,6 +323,8 @@ function FileChips({ files, onRemove, chatId }) { * input — current textarea value * onInputChange — receives new string * onSubmit — called with FormEvent | MouseEvent | TouchEvent + * onSubmitSteer — submits composed text and immediately steers + * it when a live turn can accept steering * inputRef — for caller to focus/blur (e.g. dismiss keyboard) * sending — agent is currently streaming * listening — voice input active @@ -339,6 +342,8 @@ function FileChips({ files, onRemove, chatId }) { * existing steer handler to reconcile/steer queued * messages, even before the visual fast-forward gate * is ready. + * canSubmitSteer — true when Cmd/Ctrl+Enter may submit the current + * draft through the live-turn steer path. * pendingFiles — file upload chips state * onAddFiles — receives FileList from file picker * onRemoveFile — receives chip id @@ -366,6 +371,7 @@ export default function ChatInputBar({ input, onInputChange, onSubmit, + onSubmitSteer, inputRef, sending, listening, @@ -376,6 +382,7 @@ export default function ChatInputBar({ onSteer, canSteer, canRequestSteer = canSteer, + canSubmitSteer = canRequestSteer, offline, sendFailure = null, submissionBlocked = false, @@ -439,17 +446,6 @@ export default function ChatInputBar({ function handleTextareaChange(e) { if (listeningRef?.current) onManualVoiceEdit?.(e.target.value) onInputChange(e.target.value) - e.target.style.height = 'auto' - const h = Math.min(e.target.scrollHeight, 280) - e.target.style.height = h + 'px' - // Toggle the `--tall` class only when the textarea ACTUALLY - // spans multiple lines. A single line of 16px text at line- - // height 1.45 with 8px padding measures ~31px scrollHeight, - // so a threshold of 30 was triggering --tall on every keystroke - // and dropping the cursor + mic to the bottom. 45px sits - // safely between single-line (~31) and two-line (~55). - const pill = e.target.closest('.chat__pill') - if (pill) pill.classList.toggle('chat__pill--tall', h > 45) } function handlePaste(e) { @@ -466,6 +462,7 @@ export default function ChatInputBar({ hasInput, canSteer, canRequestSteer, + canSubmitSteer, isTouchPrimary: _isTouchPrimary, }) if (!action) return @@ -474,6 +471,10 @@ export default function ChatInputBar({ onSteer() return } + if (action === 'submit-steer') { + if (!submissionBlocked) onSubmitSteer(e) + return + } if (action === 'submit') { if (!submissionBlocked) onSubmit(e) } diff --git a/frontend/src/components/ChatView/ChatView.css b/frontend/src/components/ChatView/ChatView.css index 43629cf75..a3447413a 100644 --- a/frontend/src/components/ChatView/ChatView.css +++ b/frontend/src/components/ChatView/ChatView.css @@ -218,16 +218,6 @@ max-width: 42ch; } -.chat__empty-prompts { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 8px; - max-width: min(100%, 520px); - margin-top: 4px; -} - -.chat__empty-prompt, .chat__empty-action { display: inline-flex; align-items: center; @@ -248,13 +238,11 @@ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; } -.chat__empty-prompt:hover, .chat__empty-action:hover { background: var(--surface); border-color: var(--border); } -.chat__empty-prompt:focus-visible, .chat__empty-action:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; @@ -1495,7 +1483,6 @@ target. These rows carry their disclosure state without a trailing chevron, so the whole labeled line must be comfortably tappable. */ @media (pointer: coarse) { - .chat__empty-prompt, .chat__empty-action, .chat__quick-action-chip, .chat__activity-header:not(.chat__activity-header--static), @@ -2348,7 +2335,7 @@ width: fit-content; padding: 0; border: 0; - border-radius: 8px; + border-radius: 14px; background: transparent; color: inherit; cursor: pointer; @@ -2360,10 +2347,12 @@ } .chat__attach-thumb { - height: 80px; - max-width: 160px; + /* Match the composer's attachment card after send. The thumbnail is a + consistent square crop; tapping it still opens the full source image. */ + width: 96px; + height: 96px; object-fit: cover; - border-radius: 8px; + border-radius: 14px; border: 1px solid var(--border-light); /* CONTRACT: subtle 1px hairline on opaque fill */ cursor: inherit; transition: opacity 0.15s; diff --git a/frontend/src/components/ChatView/ChatView.jsx b/frontend/src/components/ChatView/ChatView.jsx index 66b636b5f..4e00f5e38 100644 --- a/frontend/src/components/ChatView/ChatView.jsx +++ b/frontend/src/components/ChatView/ChatView.jsx @@ -82,6 +82,10 @@ import { sendAttemptIsDurable, } from './sendAttemptRecovery.js' import { persistComposerDraft, readComposerDraft } from './composerDraft.js' +import { + resetComposerTextarea, + resizeComposerTextarea, +} from './composerTextareaSizing.js' import { EMPTY_BUILD_PHASE_RAIL, accumulateBuildPhase, @@ -99,12 +103,6 @@ const _touchMql = typeof matchMedia === 'function' let _isTouchPrimary = _touchMql?.matches ?? false _touchMql?.addEventListener('change', (e) => { _isTouchPrimary = e.matches }) -const EMPTY_PROMPTS = [ - { label: 'Make Möbius mine', prompt: 'Suggest three small changes that would make this Möbius feel more like mine, then implement the best one.' }, - { label: 'Build a tiny app', prompt: 'Build a tiny useful app I can try in the next five minutes.' }, - { label: 'Show me around', prompt: 'Show me what you can change in this Möbius, and recommend a first move.' }, -] - const STOP_RETRY_DELAYS_MS = [0, 250, 700, 1200] function delay(ms) { @@ -1457,10 +1455,7 @@ export default function ChatView({ requestAnimationFrame(() => { const el = inputRef.current if (!el) return - el.style.height = 'auto' - const h = Math.min(el.scrollHeight, 280) - el.style.height = `${h}px` - el.closest('.chat__pill')?.classList.toggle('chat__pill--tall', h > 45) + resizeComposerTextarea(el, text) if (focus) { try { el.focus({ preventScroll: true }) } catch { el.focus() } @@ -1602,24 +1597,15 @@ export default function ChatView({ persistComposerDraft(chatId, input, draftAttachmentsRef.current) }, [input, chatId]) - // Auto-size textarea when a draft is restored. Cap matches the - // 280px max-height enforced by `handleTextareaChange` in - // ChatInputBar; without keeping these in sync a tall draft would - // restore visually truncated until the user types one more - // character to trigger the live-grow path. Also mirror the - // .chat__pill--tall class toggle so a restored multi-line draft - // anchors the send/mic buttons to the bottom of the pill — the - // toggle otherwise only fires on input keystrokes. - useEffect(() => { + // Text changes through input, restores, voice, send cleanup, and + // authoritative foreground reconciliation. Reconcile after every committed + // value — including the empty value — so no programmatic clear can retain a + // previous multi-line inline height. Hidden retained panes have no useful + // scrollHeight; they reconcile when `hidden` flips back to false. + useLayoutEffect(() => { const el = inputRef.current - if (el && input) { - el.style.height = 'auto' - const h = Math.min(el.scrollHeight, 280) - el.style.height = h + 'px' - const pill = el.closest('.chat__pill') - if (pill) pill.classList.toggle('chat__pill--tall', h > 45) - } - }, [chatId]) + if (el && !hidden) resizeComposerTextarea(el, input) + }, [chatId, hidden, input]) // Publish `.chat__foot`'s rendered height as `--composer-h` on // `.chat`. `.chat__list` reads this var for its bottom padding so @@ -1641,8 +1627,16 @@ export default function ChatView({ raf2 = requestAnimationFrame(measureComposerHeight) }) } + const reconcileForegroundGeometry = () => { + // Chromium can restore form/layout state independently when a document + // returns from background or the back-forward cache. Reconcile the + // textarea first; measuring only the outer foot would preserve a stale + // multi-line height on an empty composer. + resizeComposerTextarea(inputRef.current, inputValueRef.current) + applySoon() + } const onVisible = () => { - if (document.visibilityState === 'visible') applySoon() + if (document.visibilityState === 'visible') reconcileForegroundGeometry() } applySoon() @@ -1651,7 +1645,7 @@ export default function ChatView({ : null ro?.observe(footEl) window.addEventListener('resize', applySoon) - window.addEventListener('pageshow', applySoon) + window.addEventListener('pageshow', reconcileForegroundGeometry) window.visualViewport?.addEventListener('resize', applySoon) window.visualViewport?.addEventListener('scroll', applySoon) document.addEventListener('visibilitychange', onVisible) @@ -1661,7 +1655,7 @@ export default function ChatView({ if (raf2) cancelAnimationFrame(raf2) ro?.disconnect() window.removeEventListener('resize', applySoon) - window.removeEventListener('pageshow', applySoon) + window.removeEventListener('pageshow', reconcileForegroundGeometry) window.visualViewport?.removeEventListener('resize', applySoon) window.visualViewport?.removeEventListener('scroll', applySoon) document.removeEventListener('visibilitychange', onVisible) @@ -1955,6 +1949,15 @@ export default function ChatView({ // they just stopped) → original turn 1 user msg + partial get // pushed above the viewport. Keep their current scroll mode // instead — the new turn streams into view from where they were. + // Modified-Enter spans two requests (durable queue acknowledgement, then + // force-steer). Claim that whole operation synchronously so repeated + // keydowns cannot submit a second message before the steer busy state flips. + const submitSteerInFlightRef = useRef(false) + // doSend is intentionally stable and therefore must not capture the + // render-local steer implementation. Dereference the current function only + // after the queue POST settles, when a newer render may have replaced it. + const handleSteerOneRef = useRef(null) + const doSend = useCallback(async (text, opts = {}) => { if (isProviderSwitchBlocking(chatId)) return const pin = opts.pin !== false // default true @@ -2076,17 +2079,15 @@ export default function ChatView({ setComposerInput('') clearComposerFilesForSend() if (inputRef.current) { - inputRef.current.style.height = 'auto' + resetComposerTextarea(inputRef.current) // Drop the multi-line `.chat__pill--tall` class so send/mic // re-center vertically. Without this, the pill stays in // flex-end alignment after a send-from-tall and the freshly // empty textarea renders pinned to the bottom — text appears // off-center (lower than its resting position) until the - // user types again. `handleTextareaChange` re-evaluates this - // class on every keystroke, but send doesn't go through that - // path. Tap-to-focus doesn't trigger a change event either, - // so the visual stayed broken until the next keystroke. - inputRef.current.closest('.chat__pill')?.classList.remove('chat__pill--tall') + // user types again. Shared textarea sizing re-evaluates this + // on each committed value, but the synchronous reset keeps the + // send transition correct before React commits the empty value. } try { const result = await streamSend( @@ -2164,6 +2165,12 @@ export default function ChatView({ cidList: result.message?._consumed_cids, }) bridgeHook.markBridged() + } else if (opts.steerAfterQueue) { + // Ctrl/Cmd+Enter uses the same durable queue -> force-steer path + // as the visible per-row arrow. The queue acknowledgement gives + // the new row a canonical ts before steering, so a failed or + // racing steer naturally leaves the message safely queued. + await handleSteerOneRef.current?.(cid) } } // Mid-turn steer: the backend delivered the send into the live @@ -2276,10 +2283,9 @@ export default function ChatView({ setComposerInput('') clearComposerFilesForSend() if (inputRef.current) { - inputRef.current.style.height = 'auto' + resetComposerTextarea(inputRef.current) // Drop the multi-line `.chat__pill--tall` class — see queue-path // comment above for the full rationale. - inputRef.current.closest('.chat__pill')?.classList.remove('chat__pill--tall') } setSending(true) setServerRunningState(true) @@ -2615,6 +2621,15 @@ export default function ChatView({ doSend(input.trim()) } + function handleSubmitSteer(e) { + e.preventDefault() + if (isProviderSwitchBlocking(chatId)) return + if (submitSteerInFlightRef.current) return + submitSteerInFlightRef.current = true + void doSend(input.trim(), { steerAfterQueue: true }) + .finally(() => { submitSteerInFlightRef.current = false }) + } + // Cancel one queued message via DELETE. Keep reconciliation scoped to that // CID: full queue snapshots can arrive out of order when two rows are // cancelled quickly and would otherwise resurrect a sibling cancellation. @@ -3113,6 +3128,7 @@ export default function ChatView({ setSteerBusy(false) } } + handleSteerOneRef.current = handleSteerOne // Re-anchor the scroll mode when the tab returns to the foreground // (visibilitychange/pageshow/online) while a turn is active, so a @@ -3390,10 +3406,11 @@ export default function ChatView({ const canSteer = !hasPendingQuestion && connectionError !== 'disconnected' && !steerBusy && canFastForwardQueue(pendingQueue.pendingMessages, turnActive) - const canRequestSteer = !hasPendingQuestion + const canSubmitSteer = !hasPendingQuestion && connectionError !== 'disconnected' && !steerBusy && turnActive + const canRequestSteer = canSubmitSteer && pendingQueue.pendingMessages.length > 0 // ── Sticky "tap to resume" affordance ────────────────────────────── @@ -3665,18 +3682,6 @@ export default function ChatView({

What's on your mind?

-
- {EMPTY_PROMPTS.map(prompt => ( - - ))} -
)} @@ -3972,6 +3977,7 @@ export default function ChatView({ input={input} onInputChange={handleComposerInputChange} onSubmit={handleSubmit} + onSubmitSteer={handleSubmitSteer} inputRef={inputRef} sending={composerBusy} listening={listening} @@ -3982,6 +3988,7 @@ export default function ChatView({ onSteer={handleSteer} canSteer={canSteer} canRequestSteer={canRequestSteer} + canSubmitSteer={canSubmitSteer} offline={!online} sendFailure={sendFailure} submissionBlocked={providerSwitching} diff --git a/frontend/src/components/ChatView/__tests__/activityLineStructure.test.js b/frontend/src/components/ChatView/__tests__/activityLineStructure.test.js index 3b0e2cdd4..dd43f3482 100644 --- a/frontend/src/components/ChatView/__tests__/activityLineStructure.test.js +++ b/frontend/src/components/ChatView/__tests__/activityLineStructure.test.js @@ -107,7 +107,6 @@ test('lazy tool details and touch targets keep their accessibility contract', () assert.match(toolBlock, /className="chat__lazy-retry"/) assert.match(toolBlock, /\? 'status' : undefined/) assert.match(toolBlock, /\? 'polite' : undefined/) - assert.match(coarse, /\.chat__empty-prompt/) assert.match(coarse, /\.chat__empty-action/) assert.match(coarse, /\.chat__quick-action-chip/) assert.match(coarse, /\.chat__lazy-retry/) diff --git a/frontend/src/components/ChatView/__tests__/attachmentSizing.test.js b/frontend/src/components/ChatView/__tests__/attachmentSizing.test.js new file mode 100644 index 000000000..6f9a241f7 --- /dev/null +++ b/frontend/src/components/ChatView/__tests__/attachmentSizing.test.js @@ -0,0 +1,37 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const css = readFileSync(new URL('../ChatView.css', import.meta.url), 'utf8') +const msgContent = readFileSync(new URL('../MsgContent.jsx', import.meta.url), 'utf8') + +function ruleBody(selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`)) + assert.ok(match, `${selector} rule must exist`) + return match[1] +} + +test('sent image attachments match the composer card height and corners', () => { + const composer = ruleBody('.chat__attach-card--image') + const sentButton = ruleBody('.chat__attach-thumb-button') + const sent = ruleBody('.chat__attach-thumb') + + assert.match(composer, /height:\s*96px/) + assert.match(sent, /width:\s*96px/) + assert.match(sent, /height:\s*96px/) + assert.match(composer, /border-radius:\s*14px/) + assert.match(sentButton, /border-radius:\s*14px/) + assert.match(sent, /border-radius:\s*14px/) +}) + +test('sent attachments render above message text in both message paths', () => { + const attachmentNeedle = "msg.role === 'user' && = 0 && firstAttachments < blockContent) + assert.ok(secondAttachments >= 0 && secondAttachments < plainText) +}) diff --git a/frontend/src/components/ChatView/__tests__/buildPhaseRail.test.js b/frontend/src/components/ChatView/__tests__/buildPhaseRail.test.js index d24d5df58..34d76deb7 100644 --- a/frontend/src/components/ChatView/__tests__/buildPhaseRail.test.js +++ b/frontend/src/components/ChatView/__tests__/buildPhaseRail.test.js @@ -109,8 +109,10 @@ test('connection failure hides queued actions and disables composer steering', ( 'the lost-connection state should own the footer stack until Retry succeeds') assert.match(chatView, /const canSteer = !hasPendingQuestion[\s\S]*?connectionError !== 'disconnected' && !steerBusy[\s\S]*?canFastForwardQueue/, 'the visible composer steer action must be gated by pending QA and connection health') - assert.match(chatView, /const canRequestSteer = !hasPendingQuestion[\s\S]*?connectionError !== 'disconnected'[\s\S]*?!steerBusy[\s\S]*?turnActive/, - 'the keyboard steer path must be gated by pending QA and connection health too') + assert.match(chatView, /const canSubmitSteer = !hasPendingQuestion[\s\S]*?connectionError !== 'disconnected'[\s\S]*?!steerBusy[\s\S]*?turnActive/, + 'the composed-text keyboard steer path must be gated by pending QA and connection health too') + assert.match(chatView, /const canRequestSteer = canSubmitSteer[\s\S]*?pendingQueue\.pendingMessages\.length > 0/, + 'the empty-composer keyboard path must share the same gate and require queued work') }) test('a send that merely enqueues preserves the in-flight build rail', () => { diff --git a/frontend/src/components/ChatView/__tests__/composerShortcuts.test.js b/frontend/src/components/ChatView/__tests__/composerShortcuts.test.js index 8247da399..7a81fc088 100644 --- a/frontend/src/components/ChatView/__tests__/composerShortcuts.test.js +++ b/frontend/src/components/ChatView/__tests__/composerShortcuts.test.js @@ -5,22 +5,35 @@ import { resolveComposerEnterAction } from '../composerShortcuts.js' const enter = (overrides = {}) => ({ key: 'Enter', ...overrides }) -test('Cmd+Enter submits composer text', () => { +test('Cmd+Enter steers composer text when a live turn can accept it', () => { assert.equal( resolveComposerEnterAction(enter({ metaKey: true }), { hasInput: true, canSteer: true, + canSubmitSteer: true, isTouchPrimary: false, }), - 'submit', + 'submit-steer', ) }) -test('Ctrl+Enter submits composer text', () => { +test('Ctrl+Enter steers composer text when a live turn can accept it', () => { assert.equal( resolveComposerEnterAction(enter({ ctrlKey: true }), { hasInput: true, canSteer: false, + canSubmitSteer: true, + isTouchPrimary: false, + }), + 'submit-steer', + ) +}) + +test('Cmd/Ctrl+Enter submits normally when there is no steerable live turn', () => { + assert.equal( + resolveComposerEnterAction(enter({ metaKey: true }), { + hasInput: true, + canSubmitSteer: false, isTouchPrimary: false, }), 'submit', diff --git a/frontend/src/components/ChatView/__tests__/composerTextareaSizing.test.js b/frontend/src/components/ChatView/__tests__/composerTextareaSizing.test.js new file mode 100644 index 000000000..2566e9102 --- /dev/null +++ b/frontend/src/components/ChatView/__tests__/composerTextareaSizing.test.js @@ -0,0 +1,87 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +import { + resetComposerTextarea, + resizeComposerTextarea, +} from '../composerTextareaSizing.js' + +function textareaStub({ value = '', scrollHeight = 31, tall = false } = {}) { + const classes = new Set(tall ? ['chat__pill--tall'] : []) + const pill = { + classList: { + toggle(name, enabled) { + if (enabled) classes.add(name) + else classes.delete(name) + }, + remove(name) { classes.delete(name) }, + contains(name) { return classes.has(name) }, + }, + } + return { + textarea: { + value, + scrollHeight, + style: { height: tall ? '280px' : '' }, + closest: selector => selector === '.chat__pill' ? pill : null, + }, + pill, + } +} + +test('foreground reconciliation collapses an empty textarea with stale tall geometry', () => { + // Chromium can expose the old/capped client height as scrollHeight while a + // focused empty textarea is returning inside a multi-pane layout. Empty must + // not trust that measurement at all. + const { textarea, pill } = textareaStub({ value: '', scrollHeight: 280, tall: true }) + + assert.equal(resizeComposerTextarea(textarea), 0) + assert.equal(textarea.style.height, 'auto') + assert.equal(pill.classList.contains('chat__pill--tall'), false) +}) + +test('textarea sizing caps multi-line content and retains the tall alignment', () => { + const { textarea, pill } = textareaStub({ value: 'many lines', scrollHeight: 420 }) + + assert.equal(resizeComposerTextarea(textarea), 280) + assert.equal(textarea.style.height, '280px') + assert.equal(pill.classList.contains('chat__pill--tall'), true) +}) + +test('authoritative text can size before React commits it into the DOM value', () => { + const { textarea, pill } = textareaStub({ value: '', scrollHeight: 120 }) + + assert.equal(resizeComposerTextarea(textarea, 'voice transcript'), 120) + assert.equal(textarea.style.height, '120px') + assert.equal(pill.classList.contains('chat__pill--tall'), true) +}) + +test('hidden retained panes keep intrinsic height instead of receiving zero pixels', () => { + const { textarea, pill } = textareaStub({ value: '', scrollHeight: 0, tall: true }) + + assert.equal(resizeComposerTextarea(textarea), 0) + assert.equal(textarea.style.height, 'auto') + assert.equal(pill.classList.contains('chat__pill--tall'), false) +}) + +test('reset collapses immediately before React commits the empty value', () => { + const { textarea, pill } = textareaStub({ value: 'old multi-line value', scrollHeight: 280, tall: true }) + + resetComposerTextarea(textarea) + assert.equal(textarea.style.height, 'auto') + assert.equal(pill.classList.contains('chat__pill--tall'), false) +}) + +test('ChatView reconciles textarea geometry on value commits and foreground return', () => { + const source = readFileSync(new URL('../ChatView.jsx', import.meta.url), 'utf8') + const inputBarSource = readFileSync(new URL('../ChatInputBar.jsx', import.meta.url), 'utf8') + const voiceSource = readFileSync(new URL('../useVoiceInput.js', import.meta.url), 'utf8') + assert.match(source, /useLayoutEffect\(\(\) => \{[\s\S]*resizeComposerTextarea\(el, input\)[\s\S]*\}, \[chatId, hidden, input\]\)/) + assert.match(source, /const reconcileForegroundGeometry = \(\) => \{[\s\S]*resizeComposerTextarea\(inputRef\.current, inputValueRef\.current\)[\s\S]*applySoon\(\)/) + assert.match(source, /window\.addEventListener\('pageshow', reconcileForegroundGeometry\)/) + assert.doesNotMatch(inputBarSource, /resizeComposerTextarea/) + assert.doesNotMatch(voiceSource, /resizeComposerTextarea/) + const resets = source.match(/resetComposerTextarea\(inputRef\.current\)/g) || [] + assert.equal(resets.length, 2, 'both queued and immediate sends collapse stale textarea geometry') +}) diff --git a/frontend/src/components/ChatView/__tests__/optimisticSteerQueue.test.js b/frontend/src/components/ChatView/__tests__/optimisticSteerQueue.test.js index 7918c32a5..3893cebf2 100644 --- a/frontend/src/components/ChatView/__tests__/optimisticSteerQueue.test.js +++ b/frontend/src/components/ChatView/__tests__/optimisticSteerQueue.test.js @@ -74,3 +74,33 @@ test('a steer request disables sibling row actions until it settles', () => { assert.match(source, /steerBusy=\{steerBusy\}/, 'the queued tray should receive the in-flight state for its row buttons') }) + +test('the modified-Enter submit waits for durability, then reuses per-row steer', () => { + const refDeclaration = source.indexOf('const handleSteerOneRef = useRef(null)') + const doSendDeclaration = source.indexOf('const doSend = useCallback') + assert.ok( + refDeclaration >= 0 && refDeclaration < doSendDeclaration, + 'the stable doSend callback must reach the current steer implementation through a ref', + ) + assert.match( + source, + /pendingQueue\.confirmQueued\(cid,[\s\S]*?else if \(opts\.steerAfterQueue\) \{[\s\S]*?await handleSteerOneRef\.current\?\.\(cid\)/, + 'the composed message must be server-confirmed before the existing row steer consumes it', + ) + assert.doesNotMatch( + source, + /else if \(opts\.steerAfterQueue\) \{[\s\S]*?await handleSteerOne\(cid\)/, + 'doSend must not capture a render-local steer function across the queue request', + ) + const steerOneDeclaration = source.indexOf('async function handleSteerOne(cid)') + const currentAssignment = source.indexOf('handleSteerOneRef.current = handleSteerOne') + assert.ok( + steerOneDeclaration >= 0 && currentAssignment > steerOneDeclaration, + 'each render must publish the current per-row steer implementation', + ) + assert.match( + source, + /function handleSubmitSteer\(e\) \{[\s\S]*?if \(submitSteerInFlightRef\.current\) return[\s\S]*?submitSteerInFlightRef\.current = true[\s\S]*?doSend\(input\.trim\(\), \{ steerAfterQueue: true \}\)[\s\S]*?\.finally\(\(\) => \{ submitSteerInFlightRef\.current = false \}\)/, + 'the keyboard handler must synchronously guard the full queue-to-steer operation', + ) +}) diff --git a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js index f2135812a..e2ea1e24b 100644 --- a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js +++ b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js @@ -2,13 +2,16 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { + _anchorModeIntersectsContent, _anchorReapplyNeeded, _computeSpacerH, + _modeForPersistence, _pinReapplyNeeded, _scrollModeForDiagnostics, _validateSavedMode, applyMode, bottomAnchorModeFromScroll, + contentHoldModeFromScroll, gestureLayoutRetryDelay, isNearContentBottom, isNearScrollBottom, @@ -220,6 +223,7 @@ test('disclosure toggles follow only in FOLLOW_BOTTOM and otherwise hold the rea } const scrollEl = { scrollTop: 500, + clientHeight: 600, querySelectorAll: () => [row], } const follow = { kind: 'FOLLOW_BOTTOM' } @@ -758,6 +762,44 @@ test('attention nudge anchors the physical tail without enabling follow', () => 'revealing a question or Resume control must not create live-follow intent') }) +test('an off-content physical nudge stays live but persists the real-content tail', () => { + const last = { + offsetTop: 1500, + offsetHeight: 220, + dataset: { key: 'assistant-paused-tail' }, + } + const scrollEl = { + scrollHeight: 3100, + scrollTop: 700, + clientHeight: 700, + querySelector(selector) { + if (selector === '.spacer-dynamic') return { offsetHeight: 1200 } + if (selector === '[data-key="assistant-paused-tail"]') return last + return null + }, + querySelectorAll(selector) { + return selector === '.chat__msg[data-key]' ? [last] : [] + }, + } + + const liveMode = physicalBottomAnchorModeFromScroll(scrollEl) + assert.deepEqual(liveMode, { + kind: 'ANCHOR_AT', + key: 'assistant-paused-tail', + offset: -900, + }) + applyMode(scrollEl, liveMode) + assert.equal(scrollEl.scrollTop, 2400, + 'the explicit nudge reaches the true physical tail in the live mount') + + assert.deepEqual(_modeForPersistence(liveMode, [], scrollEl), { + kind: 'ANCHOR_AT', + key: 'assistant-paused-tail', + offset: 300, + defaultTail: true, + }, 'durable state normalizes the off-content nudge to the real tail') +}) + test('an unresolvable saved location falls back to a settled bottom anchor', () => { const last = { offsetTop: 900, @@ -791,35 +833,98 @@ test('an unresolvable saved location falls back to a settled bottom anchor', () assert.notEqual(mode.kind, 'FOLLOW_BOTTOM') }) -test('attention nudge anchors the physical tail without enabling follow', () => { +test('a saved anchor wholly inside reserved blank space self-heals to real content', () => { const last = { - offsetTop: 1500, + offsetTop: 500, offsetHeight: 220, - dataset: { key: 'assistant-attention-tail' }, + dataset: { key: 'assistant-question' }, } const scrollEl = { - scrollHeight: 2100, - scrollTop: 700, + scrollHeight: 1900, + scrollTop: 0, clientHeight: 700, querySelector(selector) { - return selector === '[data-key="assistant-attention-tail"]' ? last : null + if (selector === '.spacer-dynamic') return { offsetHeight: 1200 } + if (selector === '[data-key="assistant-question"]') return last + return null }, querySelectorAll(selector) { return selector === '.chat__msg[data-key]' ? [last] : [] }, } - const mode = physicalBottomAnchorModeFromScroll(scrollEl) - assert.deepEqual(mode, { + const restored = _validateSavedMode( + { kind: 'ANCHOR_AT', key: 'assistant-question', offset: -900 }, + [], + scrollEl, + ) + assert.deepEqual(restored, { kind: 'ANCHOR_AT', - key: 'assistant-attention-tail', - offset: 100, + key: 'assistant-question', + offset: 500, + defaultTail: true, }) - applyMode(scrollEl, mode) - assert.equal(scrollEl.scrollTop, 1400, - 'the move includes all composer clearance after the attention card') - assert.notEqual(mode.kind, 'FOLLOW_BOTTOM', - 'revealing an action must not create live-follow intent') +}) + +test('live persistence preserves follow while restore settles it to real content', () => { + const last = { + offsetTop: 500, + offsetHeight: 220, + dataset: { key: 'assistant-tail' }, + } + const scrollEl = { + scrollHeight: 1900, + clientHeight: 700, + querySelector(selector) { + if (selector === '.spacer-dynamic') return { offsetHeight: 1200 } + return null + }, + querySelectorAll(selector) { + return selector === '.chat__msg[data-key]' ? [last] : [] + }, + } + const follow = { kind: 'FOLLOW_BOTTOM' } + + assert.equal(_modeForPersistence(follow, [], scrollEl), follow, + 'ordinary live persistence must not erase the active follow state') + assert.deepEqual(_validateSavedMode(follow, [], scrollEl), { + kind: 'ANCHOR_AT', + key: 'assistant-tail', + offset: 500, + defaultTail: true, + }, 'mount restore still converts follow into a settled content hold') +}) + +test('a saved partially-visible anchor remains exact', () => { + const row = { + offsetTop: 500, + offsetHeight: 220, + dataset: { key: 'assistant-reading' }, + } + const saved = { + kind: 'ANCHOR_AT', key: 'assistant-reading', offset: -100, + } + const scrollEl = { + clientHeight: 700, + querySelector(selector) { + return selector === '[data-key="assistant-reading"]' ? row : null + }, + } + assert.equal(_validateSavedMode(saved, [], scrollEl), saved, + 'an anchor whose row still intersects its restored viewport is preserved') +}) + +test('the anchor invariant distinguishes content from layout reservation', () => { + const row = { offsetHeight: 220 } + assert.equal(_anchorModeIntersectsContent( + row, { offset: -100 }, 700, + ), true, 'a partially visible row is a readable location') + assert.equal(_anchorModeIntersectsContent( + row, { offset: -900 }, 700, + ), false, 'a row wholly above the viewport is blank reservation') + assert.equal(_anchorModeIntersectsContent( + row, { offset: 700 }, 700, + ), false, 'a row beginning below the viewport is not visible content') }) test('chat exit freezes the visible anchor even at the physical tail', () => { @@ -854,6 +959,60 @@ test('chat exit never infers follow mode when no message anchor exists', () => { assert.equal(modeForChatExit(scrollEl), null) }) +test('chat exit from blank reservation persists the real-content tail', () => { + const last = { + offsetTop: 500, + offsetHeight: 220, + dataset: { key: 'assistant-question' }, + } + const scrollEl = { + scrollHeight: 1900, + scrollTop: 1200, + clientHeight: 700, + querySelector(selector) { + if (selector === '.spacer-dynamic') return { offsetHeight: 1200 } + return null + }, + querySelectorAll(selector) { + return selector === '.chat__msg[data-key]' ? [last] : [] + }, + } + + assert.deepEqual(modeForChatExit(scrollEl), { + kind: 'ANCHOR_AT', + key: 'assistant-question', + offset: 500, + defaultTail: true, + }) +}) + +test('leaving the physical bottom inside blank reservation retires follow', () => { + const last = { + offsetTop: 500, + offsetHeight: 220, + dataset: { key: 'assistant-question' }, + } + const scrollEl = { + scrollHeight: 1900, + scrollTop: 1200, + clientHeight: 700, + querySelector(selector) { + if (selector === '.spacer-dynamic') return { offsetHeight: 1200 } + return null + }, + querySelectorAll(selector) { + return selector === '.chat__msg[data-key]' ? [last] : [] + }, + } + + assert.deepEqual(contentHoldModeFromScroll(scrollEl), { + kind: 'ANCHOR_AT', + key: 'assistant-question', + offset: 500, + defaultTail: true, + }) +}) + test('applyMode PIN is a no-op when the cid resolves no row (strict, no fallback)', () => { // The ts-swap that once forced a last-row fallback cannot happen: the row // carries its final cid from mint. An unresolved cid pins nothing (the @@ -918,7 +1077,7 @@ test('spacer reservation returns zero before there is a user message', () => { }) test('viewport growth keeps a question-answer anchor reachable before output resumes', () => { - const anchor = { offsetTop: 1320 } + const anchor = { offsetTop: 1320, offsetHeight: 220 } const scrollEl = { clientHeight: 960, querySelector(selector) { @@ -944,7 +1103,7 @@ test('viewport growth keeps a question-answer anchor reachable before output res }) test('anchor reservation disappears once real content makes the target reachable', () => { - const anchor = { offsetTop: 1320 } + const anchor = { offsetTop: 1320, offsetHeight: 220 } const scrollEl = { clientHeight: 960, querySelector(selector) { @@ -959,6 +1118,24 @@ test('anchor reservation disappears once real content makes the target reachable ) }) +test('a live off-content anchor reserves its exact reader-owned position', () => { + const anchor = { offsetTop: 500, offsetHeight: 220 } + const scrollEl = { + clientHeight: 700, + querySelector(selector) { + return selector === '[data-key="assistant-question"]' ? anchor : null + }, + } + const mode = { + kind: 'ANCHOR_AT', key: 'assistant-question', offset: -900, + } + assert.equal( + _computeSpacerH(scrollEl, { offsetHeight: 700 }, { offsetTop: 100 }, 700, mode), + 1400, + 'live reader ownership survives in reserved room; persistence rejects it', + ) +}) + test('queued tray does not shorten spacer reservation', () => { // `.chat__list` bottom padding already includes the full measured footer // height (queue tray + composer). Subtracting the tray again makes the diff --git a/frontend/src/components/ChatView/composerShortcuts.js b/frontend/src/components/ChatView/composerShortcuts.js index edd4bca04..d69146a3a 100644 --- a/frontend/src/components/ChatView/composerShortcuts.js +++ b/frontend/src/components/ChatView/composerShortcuts.js @@ -2,6 +2,7 @@ export function resolveComposerEnterAction(event, { hasInput = false, canSteer = false, canRequestSteer = canSteer, + canSubmitSteer = canRequestSteer, isTouchPrimary = false, } = {}) { if (!event || event.key !== 'Enter' || event.shiftKey) return null @@ -9,7 +10,10 @@ export function resolveComposerEnterAction(event, { const modifiedEnter = !!(event.metaKey || event.ctrlKey) if (!modifiedEnter && isTouchPrimary) return null - if (hasInput) return 'submit' + if (hasInput) { + if (modifiedEnter && canSubmitSteer) return 'submit-steer' + return 'submit' + } if (canRequestSteer) return 'steer' return 'noop' } diff --git a/frontend/src/components/ChatView/composerTextareaSizing.js b/frontend/src/components/ChatView/composerTextareaSizing.js new file mode 100644 index 000000000..998b55b26 --- /dev/null +++ b/frontend/src/components/ChatView/composerTextareaSizing.js @@ -0,0 +1,51 @@ +export const COMPOSER_TEXTAREA_MAX_HEIGHT = 280 +export const COMPOSER_TEXTAREA_TALL_THRESHOLD = 45 + +function composerPill(textarea) { + return textarea?.closest?.('.chat__pill') || null +} + +/** + * Reconcile the textarea's inline height with its current DOM value. + * + * Composer text changes through more than the input event: send cleanup, + * failed-send reconciliation, voice input, restored drafts, and browser + * foregrounding can all update or restore it. Keeping this operation shared + * prevents an empty textarea from retaining a previous multi-line height. + */ +export function resizeComposerTextarea(textarea, value = textarea?.value) { + if (!textarea?.style) return 0 + + // Empty is a semantic one-line state, not a geometry question. During a + // multi-pane mount / foreground transition Chromium can briefly report an + // empty textarea's scrollHeight as its old or available flex height (often + // the 280px cap). Measuring that transient value makes the blank composer + // fill the pane until the next keystroke. Reset deterministically instead. + if (value === '') { + resetComposerTextarea(textarea) + return 0 + } + + textarea.style.height = 'auto' + const measured = Number(textarea.scrollHeight) || 0 + + // Retained workspace panes can be display:none while React commits a state + // update. Their scrollHeight is 0, which is not useful geometry; leave the + // intrinsic one-row height in place and reconcile when the pane is visible. + if (measured <= 0) return 0 + + const height = Math.min(measured, COMPOSER_TEXTAREA_MAX_HEIGHT) + textarea.style.height = `${height}px` + composerPill(textarea)?.classList?.toggle( + 'chat__pill--tall', + height > COMPOSER_TEXTAREA_TALL_THRESHOLD, + ) + return height +} + +/** Collapse immediately while React is still committing an empty value. */ +export function resetComposerTextarea(textarea) { + if (!textarea?.style) return + textarea.style.height = 'auto' + composerPill(textarea)?.classList?.remove?.('chat__pill--tall') +} diff --git a/frontend/src/components/ChatView/hooks/__tests__/useVoiceInput.test.js b/frontend/src/components/ChatView/hooks/__tests__/useVoiceInput.test.js index 881b54356..175c518b3 100644 --- a/frontend/src/components/ChatView/hooks/__tests__/useVoiceInput.test.js +++ b/frontend/src/components/ChatView/hooks/__tests__/useVoiceInput.test.js @@ -25,20 +25,16 @@ function installSpeechRecognition() { return instances } -test('live dictation grows to the composer cap and bottom-anchors its mic', () => { +test('live dictation delegates geometry to the controlled composer value owner', () => { const instances = installSpeechRecognition() - const toggles = [] + let closestCalls = 0 const input = { value: '', scrollHeight: 120, style: {}, - closest: () => ({ - classList: { toggle: (...args) => toggles.push(args) }, - }), + closest: () => { closestCalls += 1 }, } const transcripts = [] - const prevRaf = globalThis.requestAnimationFrame - globalThis.requestAnimationFrame = fn => { fn(); return 1 } try { const { result } = renderHook(() => useVoiceInput({ onTranscript: text => transcripts.push(text), @@ -48,10 +44,9 @@ test('live dictation grows to the composer cap and bottom-anchors its mic', () = instances[0].onresult(speechResult('a sufficiently long dictated message')) assert.equal(transcripts.at(-1), 'a sufficiently long dictated message') - assert.equal(input.style.height, '120px') - assert.deepEqual(toggles.at(-1), ['chat__pill--tall', true]) + assert.equal(input.style.height, undefined) + assert.equal(closestCalls, 0, 'the hook must not force a second layout') } finally { - globalThis.requestAnimationFrame = prevRaf delete globalThis.window } }) @@ -63,14 +58,12 @@ test('a manual edit while listening survives late speech results and rebases dic let timerId = 0 const prevTimeout = globalThis.setTimeout const prevClearTimeout = globalThis.clearTimeout - const prevRaf = globalThis.requestAnimationFrame globalThis.setTimeout = fn => { const id = ++timerId timers.set(id, fn) return id } globalThis.clearTimeout = id => timers.delete(id) - globalThis.requestAnimationFrame = fn => { fn(); return 1 } try { const input = { value: 'draft', style: {}, scrollHeight: 30, closest: () => null } const { result } = renderHook(() => useVoiceInput({ @@ -94,7 +87,6 @@ test('a manual edit while listening survives late speech results and rebases dic } finally { globalThis.setTimeout = prevTimeout globalThis.clearTimeout = prevClearTimeout - globalThis.requestAnimationFrame = prevRaf delete globalThis.window } }) diff --git a/frontend/src/components/ChatView/useScrollMode.js b/frontend/src/components/ChatView/useScrollMode.js index 803811fc1..e9e659ec5 100644 --- a/frontend/src/components/ChatView/useScrollMode.js +++ b/frontend/src/components/ChatView/useScrollMode.js @@ -131,15 +131,21 @@ const _scrollModes = (() => { })() -/** Returns the first message
  • whose bottom edge is past the - * viewport top — i.e. the topmost partially-visible message. - * Used to resolve a fresh ANCHOR_AT when the user scrolls. */ +/** Returns the topmost intersecting message, or the last real row while the + * viewport is inside the dynamic reservation below the transcript. + * + * That fallback is load-bearing for LIVE reader ownership: a gesture through + * reserved room still needs an anchor so streaming/layout work cannot move the + * viewport underneath the reader. Lifecycle save/restore validates that the + * anchor intersects real content and normalizes this live-only negative offset + * to the real transcript tail before persistence. */ function _topmostVisibleMsg(scrollEl) { const items = scrollEl.querySelectorAll('.chat__msg[data-key]') const top = scrollEl.scrollTop + const bottom = top + scrollEl.clientHeight for (const el of items) { - const bottom = el.offsetTop + el.offsetHeight - if (bottom > top) return el + const itemBottom = el.offsetTop + el.offsetHeight + if (itemBottom > top && el.offsetTop < bottom) return el } return items[items.length - 1] || null } @@ -168,6 +174,24 @@ export function anchorModeFromScroll(scrollEl) { } +/** Lifecycle anchors must describe visible conversation content. Live scroll + * handling may temporarily anchor reserved room, but foreground/chat restore + * must never recreate that blank viewport. */ +function _contentAnchorModeFromScroll(scrollEl) { + if (!scrollEl) return null + const row = _topmostVisibleMsg(scrollEl) + if (!row?.dataset?.key) return null + const mode = { + kind: 'ANCHOR_AT', + key: row.dataset.key, + offset: row.offsetTop - scrollEl.scrollTop, + } + return _anchorModeIntersectsContent(row, mode, scrollEl?.clientHeight) + ? mode + : null +} + + /** Create a settled anchor with the latest real conversation content at the * viewport bottom. This is a one-time restoration target, NOT FOLLOW_BOTTOM: * later streaming/layout growth cannot drag the reader after return. */ @@ -197,7 +221,9 @@ export function bottomAnchorModeFromScroll(scrollEl) { * or paused card too: composer clearance, any remaining reservation, and the * card's primary action. Keep that one-shot navigation as ANCHOR_AT rather * than FOLLOW_BOTTOM so revealing a control cannot manufacture live-follow - * intent for a later answer or resume. + * intent for a later answer or resume. Persistence independently rejects an + * off-content physical anchor, so this live navigation cannot recreate a + * blank viewport on reload. */ export function physicalBottomAnchorModeFromScroll(scrollEl) { if (!scrollEl) return null @@ -217,6 +243,18 @@ export function physicalBottomAnchorModeFromScroll(scrollEl) { } +/** Freeze a viewport to real conversation content. + * + * A reader can move away from the physical bottom while the viewport is + * wholly inside dynamic spacer. There is no exact visible row to anchor in + * that case, but the gesture must still retire live follow. Settle at the + * latest real-content tail rather than leaving FOLLOW_BOTTOM armed. */ +export function contentHoldModeFromScroll(scrollEl) { + return _contentAnchorModeFromScroll(scrollEl) + || bottomAnchorModeFromScroll(scrollEl) +} + + /** Resolve the DOM row a PIN_USER_MSG targets: the user row whose * `data-cid` equals the mode's cid. * @@ -300,6 +338,20 @@ function _anchorEl(scrollEl, key) { return scrollEl.querySelector(`[data-key="${esc}"]`) } +/** The defining ANCHOR_AT invariant: its row intersects the viewport encoded + * by `offset`. Negative offsets are valid while the row remains partially + * visible; an offset beyond either edge describes layout reservation, not a + * readable conversation location. */ +export function _anchorModeIntersectsContent(row, mode, viewportHeight) { + const offset = Number(mode?.offset) + return !!row + && Number.isFinite(offset) + && Number.isFinite(viewportHeight) + && viewportHeight > 0 + && offset < viewportHeight + && offset > -row.offsetHeight +} + /** The ANCHOR_AT twin of `_pinReapplyNeeded` — the SAME two-case repair. A * settled anchor drifts off its reader-chosen position when either the anchor * element's offsetTop SHIFTED (content grew above it) or scrollTop was CLAMPED @@ -345,12 +397,32 @@ export function _validateSavedMode(saved, messages, scrollEl) { if (saved.kind === 'ANCHOR_AT') { const sel = `[data-key="${(typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(saved.key) : saved.key}"]` - return scrollEl?.querySelector(sel) ? saved : holdBottom() + const row = scrollEl?.querySelector(sel) + // A resolvable row is not enough: an old build could persist that row with + // a huge negative offset while the viewport sat wholly in spacer below it. + // Enforce the same content-intersection invariant used by spacer sizing, + // self-healing every off-content restore to the real tail. + return _anchorModeIntersectsContent(row, saved, scrollEl?.clientHeight) + ? saved + : holdBottom() } return holdBottom() } +/** Normalize durable reader locations without collapsing live mode state. + * + * FOLLOW_BOTTOM and PIN_USER_MSG are useful while this mount is active and + * are already converted to settled restore modes by `_validateSavedMode` on + * the next mount. ANCHOR_AT is the only mode whose stored geometry can point + * wholly into spacer, so validate that location before every write. */ +export function _modeForPersistence(mode, messages, scrollEl) { + return mode?.kind === 'ANCHOR_AT' + ? _validateSavedMode(mode, messages, scrollEl) + : mode +} + + /** Spacer height needed so the latest user message can sit near the * top of the viewport, with the PIN_OFFSET breathing room above it. While an * ANCHOR_AT hold is active, also reserve enough room to keep that exact target @@ -625,7 +697,7 @@ export function readerInputNeedsFrameRelease( * FOLLOW_BOTTOM afterward. */ export function modeForForegroundReturn(scrollEl) { if (!scrollEl) return null - return anchorModeFromScroll(scrollEl) + return contentHoldModeFromScroll(scrollEl) } @@ -636,7 +708,7 @@ export function modeForForegroundReturn(scrollEl) { * yanking the reader to the latest tail. */ export function modeForChatExit(scrollEl) { if (!scrollEl) return null - return anchorModeFromScroll(scrollEl) + return contentHoldModeFromScroll(scrollEl) } @@ -958,18 +1030,27 @@ export default function useScrollMode({ sessionStorage.setItem('chat-mode', JSON.stringify(_scrollModes)) return } - const mode = freezeToCurrentPosition + const candidate = freezeToCurrentPosition ? (modeForChatExit(scrollRef.current) || modeRef.current) : modeRef.current + // One persistence gate for every lifecycle path. Invalid ANCHOR_AT + // geometry is normalized before it reaches sessionStorage. Live + // FOLLOW_BOTTOM/PIN_USER_MSG remains observable while mounted; the + // restore gate settles those modes on the next mount. + const mode = _modeForPersistence( + candidate, messagesRef.current, scrollRef.current, + ) if (mode && mode.kind !== 'INITIAL') { if (freezeToCurrentPosition) { transitionMode(mode, 'lifecycle:chat-exit') } _scrollModes[chatId] = mode - sessionStorage.setItem('chat-mode', JSON.stringify(_scrollModes)) + } else { + delete _scrollModes[chatId] } + sessionStorage.setItem('chat-mode', JSON.stringify(_scrollModes)) } catch {} - }, [chatId, scrollRef, transitionMode]) + }, [chatId, messagesRef, scrollRef, transitionMode]) const settleNonPin = useCallback(({ retireFollow = false, @@ -981,7 +1062,7 @@ export default function useScrollMode({ && !(retireFollow && kind === 'FOLLOW_BOTTOM')) { return modeRef.current } - const anchor = anchorModeFromScroll(scrollRef.current) + const anchor = contentHoldModeFromScroll(scrollRef.current) return anchor ? transitionMode(anchor, event) : modeRef.current }, [scrollRef, transitionMode]) @@ -1664,7 +1745,11 @@ export default function useScrollMode({ 'reader:physical-bottom', ) } else { - const anchor = anchorModeFromScroll(scrollEl) + // Moving away from the physical bottom always retires live follow. + // If the viewport is wholly inside reserved spacer there is no exact + // row anchor, so settle on the real-content tail instead of leaving a + // stale FOLLOW_BOTTOM armed for the next content resize. + const anchor = contentHoldModeFromScroll(scrollEl) if (anchor) transitionMode(anchor, 'reader:hold-anchor') } persistMode() diff --git a/frontend/src/components/ChatView/useVoiceInput.js b/frontend/src/components/ChatView/useVoiceInput.js index 9769c2d02..d95699417 100644 --- a/frontend/src/components/ChatView/useVoiceInput.js +++ b/frontend/src/components/ChatView/useVoiceInput.js @@ -23,10 +23,11 @@ import useScreenWakeLock from '../../hooks/useScreenWakeLock.js' * @param {object} options * @param {(text: string) => void} options.onTranscript * Called with the concatenated final+interim transcript on every - * onresult event. Caller writes this into the composer textarea. + * onresult event. Caller commits this into the controlled composer; the + * composer layout effect owns its post-commit textarea sizing. * @param {React.RefObject} options.inputRef - * The composer textarea — used for auto-height resize on transcript - * growth and to seed voiceFinalRef with the current value on start. + * The composer textarea — used to seed voiceFinalRef with the current value + * on start and to preserve the current value in permission-error copy. * * @returns {{ * listening: boolean, @@ -87,15 +88,6 @@ export default function useVoiceInput({ onTranscript, inputRef }) { } const text = voiceFinalRef.current + interim onTranscript(text) - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.style.height = 'auto' - const h = Math.min(inputRef.current.scrollHeight, 280) - inputRef.current.style.height = h + 'px' - inputRef.current.closest('.chat__pill') - ?.classList.toggle('chat__pill--tall', h > 45) - } - }) } rec.onerror = (e) => { diff --git a/frontend/src/components/ProviderModelPicker/ProviderModelPicker.jsx b/frontend/src/components/ProviderModelPicker/ProviderModelPicker.jsx index 7f3303e73..1abfa6dbe 100644 --- a/frontend/src/components/ProviderModelPicker/ProviderModelPicker.jsx +++ b/frontend/src/components/ProviderModelPicker/ProviderModelPicker.jsx @@ -16,6 +16,8 @@ * for older generations stay listed so existing chats that persisted them in * agent_settings_json keep resolving (the API treats them as aliases). */ export const CLAUDE_MODELS = [ + { value: 'claude-fable-5', label: 'Fable 5' }, + { value: 'claude-sonnet-5', label: 'Sonnet 5' }, { value: 'claude-opus-4-8', label: 'Opus 4.8' }, { value: 'claude-opus-4-7', label: 'Opus 4.7' }, { value: 'claude-opus-4-6', label: 'Opus 4.6' }, diff --git a/frontend/src/components/SettingsView/SettingsView.jsx b/frontend/src/components/SettingsView/SettingsView.jsx index 7b24cbd0a..5393665e9 100644 --- a/frontend/src/components/SettingsView/SettingsView.jsx +++ b/frontend/src/components/SettingsView/SettingsView.jsx @@ -7,6 +7,7 @@ import Sun from 'lucide-react/dist/esm/icons/sun.mjs' import { api, clearQueryCache, clearToken } from '../../api/client.js' import { authQueries, modelQueries, settingsQueries, themeQueries, versionQueries } from '../../hooks/queries.js' import { platformVersionIdentity } from '../../lib/platformVersionIdentity.js' +import { settleBackgroundAgentSave } from '../../lib/backgroundAgentSave.js' import { PROVIDER_AVAILABILITY_PHASE, resolveProviderAvailability, @@ -56,6 +57,10 @@ const FALLBACK_MODEL_ROWS = { claude: CLAUDE_MODELS.map((m) => ({ id: m.value, label: m.label, available: true })), codex: CODEX_MODELS.map((m) => ({ id: m.value, label: m.label, available: true })), } +const DEFAULT_BACKGROUND_MODELS = { + claude: 'claude-opus-4-8', + codex: 'gpt-5.6-terra', +} // POST /platform/apply is the authoritative outcome of the mutation it just // performed. Project that result into the status shape immediately so a failed @@ -92,6 +97,10 @@ function defaultModel(provider) { return FALLBACK_MODEL_ROWS[provider]?.[0]?.id || '' } +function defaultBackgroundModel(provider) { + return DEFAULT_BACKGROUND_MODELS[provider] || defaultModel(provider) +} + function isKnownProvider(provider) { return PROVIDER_CHOICES.some(p => p.id === provider) } @@ -112,7 +121,7 @@ function normalizeBackgroundAgents(backgroundAgents, defaultProvider = 'claude') if (!provider || seen.has(provider)) return rows.push({ provider, - model: choice?.model || defaultModel(provider), + model: choice?.model || defaultBackgroundModel(provider), effort: choice?.effort || defaultEffort(provider), enabled: Object.prototype.hasOwnProperty.call(choice || {}, 'enabled') ? choice.enabled !== false @@ -438,6 +447,9 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = const [manageModelsOpen, setManageModelsOpen] = useState(false) const setupFocusRefs = useRef({}) const [attentionSection, setAttentionSection] = useState('') + const configuredProvidersRef = useRef(configuredProviders) + const authProvidersAtStartRef = useRef(null) + configuredProvidersRef.current = configuredProviders const setSetupFocusRef = useCallback((section, node) => { if (node) setupFocusRefs.current[section] = node @@ -491,14 +503,15 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = return FALLBACK_MODEL_ROWS[provider] || [] }, [modelRegistryQuery.data]) - const persistBackgroundAgents = useCallback((draft) => { + const persistBackgroundAgents = useCallback((draft, companionSettings = {}) => { const rows = Array.isArray(draft) ? draft : [] const enabled = rows.filter(row => row.enabled !== false) if (!enabled.length) { setBackgroundError('Choose at least one background model.') - return Promise.resolve() + return Promise.resolve(false) } const reqId = ++backgroundSaveReqRef.current + const isCompanionSave = Object.keys(companionSettings).length > 0 setBackgroundError('') const save = backgroundSaveChainRef.current.catch(() => {}).then(async () => { try { @@ -517,18 +530,25 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = primary: toChoice(enabled[0]), fallback: enabled[1] ? toChoice(enabled[1]) : null, } - const res = await api.settings.save({ background_agents: payload }) - if (reqId !== backgroundSaveReqRef.current) return - if (!res.ok) { - let detail = '' - try { detail = (await res.json()).detail || '' } catch {} - throw new Error(detail || 'Could not save background agents.') - } + // A first provider connection also establishes the interactive default. + // Keep that transition in one settings write so disk failure cannot + // persist one half while the UI reports the whole setup as complete. + const res = await api.settings.save({ + ...companionSettings, + background_agents: payload, + }) + const { stale } = await settleBackgroundAgentSave( + res, + () => reqId !== backgroundSaveReqRef.current, + ) + if (stale) return true settingsQueries.owner.invalidate(queryClient) + return true } catch (err) { - if (reqId === backgroundSaveReqRef.current) { + if (reqId === backgroundSaveReqRef.current || isCompanionSave) { setBackgroundError(err.message || 'Could not save background agents.') } + return false } }) backgroundSaveChainRef.current = save @@ -725,20 +745,61 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = // render, which combined with the row's CSS transitions made the // panel feel jittery. With the updater form, deps are empty. const toggleClaudeAuth = useCallback( - () => setExpandedAuth(prev => prev === 'claude' ? null : 'claude'), + () => setExpandedAuth(prev => { + if (prev !== 'claude') { + authProvidersAtStartRef.current = new Set(configuredProvidersRef.current) + } + return prev === 'claude' ? null : 'claude' + }), [], ) const toggleCodexAuth = useCallback( - () => setExpandedAuth(prev => prev === 'codex' ? null : 'codex'), + () => setExpandedAuth(prev => { + if (prev !== 'codex') { + authProvidersAtStartRef.current = new Set(configuredProvidersRef.current) + } + return prev === 'codex' ? null : 'codex' + }), [], ) - const onClaudeAuthDone = useCallback(() => { - setExpandedAuth(null) - }, []) - const onCodexAuthDone = useCallback(() => { + const onProviderConnected = useCallback(async (provider) => { + const providersBefore = authProvidersAtStartRef.current || configuredProviders + const newlyConnected = !providersBefore.has(provider) + if (newlyConnected) { + const current = backgroundDraftRef.current || normalizeBackgroundAgents( + settingsQuery.data?.background_agents, + providerFromSettings(settingsQuery.data), + ) + const connectedRow = { + ...(current.find(row => row.provider === provider) || { provider }), + enabled: true, + model: defaultBackgroundModel(provider), + effort: defaultEffort(provider), + } + const rest = current.filter(row => row.provider !== provider) + const next = providersBefore.size === 0 + ? [connectedRow, ...rest.map(row => ({ ...row, enabled: false }))] + : current.map(row => row.provider === provider ? connectedRow : row) + backgroundDraftRef.current = next + setBackgroundDraft(next) + const saved = await persistBackgroundAgents( + next, + providersBefore.size === 0 ? { provider } : {}, + ) + // Authentication itself succeeded, but keep the panel and visible error + // in place until its associated defaults are durably saved. + if (!saved) return + } + authProvidersAtStartRef.current = null settingsQueries.owner.invalidate(queryClient) setExpandedAuth(null) - }, [queryClient]) + }, [configuredProviders, persistBackgroundAgents, queryClient, settingsQuery.data]) + const onClaudeAuthDone = useCallback(() => { + onProviderConnected('claude') + }, [onProviderConnected]) + const onCodexAuthDone = useCallback(() => { + onProviderConnected('codex') + }, [onProviderConnected]) async function toggleTheme() { if (themeSwitching) return @@ -1285,7 +1346,7 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = }} onModelChange={(model, effort) => setBackgroundProviderChoice(row.provider, { enabled: !!model, - model: model || defaultModel(row.provider), + model: model || defaultBackgroundModel(row.provider), ...(effort ? { effort } : {}), })} onEffortChange={(effort) => setBackgroundProviderChoice(row.provider, { effort })} diff --git a/frontend/src/components/Shell/Shell.jsx b/frontend/src/components/Shell/Shell.jsx index 29b7c27d0..247b5c0fe 100644 --- a/frontend/src/components/Shell/Shell.jsx +++ b/frontend/src/components/Shell/Shell.jsx @@ -48,8 +48,11 @@ import { } from '../../lib/appRecovery.js' import { BEFORE_SHELL_RELOAD_EVENT } from '../../lib/shellReloadEvents.js' import { + acknowledgeAppActivity, + appAttentionIds, freshChatBuiltApps, freshAppIds, + withAppActivitySeen, withAppsFlagged, withoutAppFlagged, } from './newAppAttention.js' @@ -425,14 +428,14 @@ export default function Shell() { const handleImmersive = useCallback((appId, value) => { dispatchImmersive({ type: 'request', appId, value }) }, []) - // Immersive-solo is a full-screen takeover, so per the ABSOLUTE builder - // invariant ("no exceptions, no special casing") it applies ONLY in single-screen - // mode. In builder mode an immersive request never seizes the workspace — the app - // stays a normal pane (this also keeps the bar + exit button, which key off this - // flag, out of builder). The request (immersiveAppId) is retained, so switching - // to single mode with the holder focused solos it exactly as landed. - const immersiveActive = effectiveViewMode === 'single' - && isImmersiveActive(immersiveAppId, activeView, activeAppId) + // Immersive is a temporary overlay lease, independent of the durable builder / + // single worlds. A verified request from the focused app may therefore solo + // that app over EITHER world; clearing the lease reveals the exact world below + // without changing its workspace mode, pane tree, tabs, or single-screen slot. + // Settings keeps its builder invariant because isImmersiveActive additionally + // requires the active shell view to be the requesting canvas, and AppCanvas + // forwards live requests only from its focused active frame. + const immersiveActive = isImmersiveActive(immersiveAppId, activeView, activeAppId) useLayoutEffect(() => { if (!immersiveActive) return const drawer = document.getElementById('navigation-drawer') @@ -1033,13 +1036,15 @@ export default function Shell() { // single leaf, where this single-pane .shell__tabstrip stands in for the // tiled WorkspaceChrome strips, giving phone users the drag source), riding // an exit beat or a single-mode drag preview with the rest of the tiled - // presentation, and NEVER rendered in single mode (owner: tabs exist in one - // world and don't exist in the other). The legacy tabStripEngaged latch is + // presentation, and NEVER rendered in single mode OR over an immersive lease + // (the shell exit replaces every builder navigation surface). The legacy + // tabStripEngaged latch is // the KILL-SWITCH world's rule only (engaged after 2+ tabs) — letting it // leak into the flag-ON formula painted the parked builder tree's strip // over single mode whenever the latch was set. An empty workspace (no tabs) // shows nothing either way — the >= 1 gate stays. - const tabStripVisible = (SPLITS ? effectiveViewMode === 'panes' : tabStripEngaged) + const tabStripVisible = !immersiveActive + && (SPLITS ? effectiveViewMode === 'panes' : tabStripEngaged) && openTabs.length >= 1 // tabKey -> { paneId, CONTENT rect } (pane rect minus its strip) of the active @@ -1527,9 +1532,9 @@ export default function Shell() { tabCount: openTabs.length, dismissed: wsCoachmarkDismissed, // M6: only where the tab strip actually exists — the EFFECTIVE builder world — - // and never over an immersive-solo (z-120). Immersive is single-mode only, so - // the panes gate already excludes it; the explicit check is the last line of - // defense if that coupling ever changes. + // and never over an immersive lease (z-120). Immersive may temporarily cover + // either durable world, so the explicit check keeps the hint with the chrome it + // teaches instead of painting it over the focused app. builderWorld: effectiveViewMode === 'panes' && !immersiveActive, }) // Auto-dismiss after 12s — deliberately NOT on an unrelated pointerdown (§7.2). @@ -1595,6 +1600,10 @@ export default function Shell() { // Ids of apps that appeared in the fetched list AFTER this session's // baseline — the drawer renders a subtle accent dot until each is opened. const [newAppIds, setNewAppIds] = useState(() => new Set()) + const appAttentionSet = useMemo( + () => appAttentionIds(apps, newAppIds, visibleAppIds), + [apps, newAppIds, visibleAppIds], + ) // First-sign-in walkthrough. The query result is the source of // truth — backend persists completion via // POST /api/owner/walkthrough/complete. We render the overlay iff @@ -1715,6 +1724,35 @@ export default function Shell() { for (const id of visibleAppIds) clearAppAttention(Number(id)) }, [visibleAppIds, clearAppAttention]) + // Opening an app acknowledges its durable background activity. Optimistic + // cache clearing removes the dot immediately; server truth is restored on a + // failed request. In-flight keys include the observed activity version: + // duplicate renders share one request, while genuinely newer activity can + // be acknowledged independently without waiting for an older request. + const appActivityAckRef = useRef(new Set()) + useEffect(() => { + for (const rawId of visibleAppIds) { + const appId = Number(rawId) + if (Number.isNaN(appId)) continue + const app = apps.find(row => Number(row.id) === appId) + if (!app?.has_unseen_activity || !app?.unseen_activity_version) continue + const observedActivityVersion = app.unseen_activity_version + acknowledgeAppActivity({ + appId, + activityVersion: observedActivityVersion, + inFlight: appActivityAckRef.current, + request: api.apps.markActivitySeen, + clearCached: (seenAppId, seenThroughVersion) => { + queryClient.setQueryData( + appQueries.keys.all, + rows => withAppActivitySeen(rows, seenAppId, seenThroughVersion), + ) + }, + restoreServerTruth: () => appQueries.list.invalidate(queryClient), + }) + } + }, [visibleAppIds, apps, queryClient]) + // Immersive games request OS fullscreen to also drop the Android status bar // and paint under the notch — but ENTER must come from the app, because the // Fullscreen API needs the user gesture, and the gameplay tap lands in the @@ -2358,6 +2396,11 @@ export default function Shell() { // to bump appVersions / cycle iframe keys — that would tear // down running apps for a CSS swap and lose their state. loadTheme() + } else if (ev.type === 'app_activity') { + // The durable marker was committed with an app-attributed notification. + // A refetch surfaces the dot; if the app is already visible, the effect + // above immediately acknowledges it instead of leaving a stale nudge. + refreshApps() } else if (ev.type === 'app_updated' || ev.type === 'app_created') { const placementRequest = workspaceRequestFromSystemEvent(ev) // Refresh server truth before warming or placing. app_updated is @@ -3233,7 +3276,7 @@ export default function Shell() { }} streamingChatIds={streamingChatIds} attentionChatIds={attentionChatIds} - newAppIds={newAppIds} + newAppIds={appAttentionSet} settingsWarning={providerAuth.needsAttention} dragActiveRef={dragActiveRef} /> @@ -3580,16 +3623,15 @@ export default function Shell() { stripMotion={wrapperMotion} streamingChatIds={streamingChatIds} attentionChatIds={attentionChatIds} - newAppIds={newAppIds} + newAppIds={appAttentionSet} /> )} {/* SHELL-provided immersive exit. With the top bar gone the drawer toggle is unreachable, so this floating button is the guaranteed way back — an app can never trap the user in immersive mode. - Exit only clears the shell-side request; the app re-enters by - posting again (which a mounted app won't do until it remounts), - so the user's choice sticks for the rest of the visit. */} + Exit only clears the shell-side request; re-entry requires another + explicit app post, so the user remains in control. */} {immersiveActive && (