Benedictus Kent Rachmat commited on
Commit
f506bd0
·
unverified ·
1 Parent(s): 93e1c88

web UI overhaul + fix #32 (plan phase save) (#43)

Browse files

Web UI (Chainlit):
- Rebrand to Codabench: blue theme + Codabench wordmark/globe logos.
- One settings gear in the header (theme · Readme · logout); hide the native
theme/avatar/Readme controls.
- Phase bar: instant hover tooltips per phase, and the next phase pill becomes
clickable to advance once its predecessor's artifact exists.
- Always-visible cost/context widget; fix the context % (it counted only
uncached input tokens, so it read ~0% — now includes cache read/creation).
- Per-turn tool-step log collapses into a hide/unhide <details> when the
answer lands.
- Tutorial-style Readme; greeting starter examples + a downloadable example
bundle for the validate path.
- Serve the SPA shell + custom JS/CSS with no-store so reloads stop running a
stale chat.js/login.css.
- Banner no longer persists on the landing; workspace panel starts below the
header so it no longer clips the phase bar.

Core (fix #32 — "plan phase finished without saving specs/implementation_plan.md"):
- The plan skill told the agent to save with
autocodabench_snapshot_spec(name="implementation_plan", ...) but the tool
parameter is `filename`, written verbatim with no `.md`, so the file landed
at specs/implementation_plan and the pipeline reported it as never saved.
- Correct the skill to filename="implementation_plan.md", add a tolerant
_resolve_plan_file() that promotes an extension-less save, and add regression
tests.

src/autocodabench/agent/pipeline.py CHANGED
@@ -136,6 +136,26 @@ def _find_bundle(run_dir: Path) -> tuple[Path | None, Path | None]:
136
  return None, None
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  def _update_run_slug(run_dir: Path, slug: str) -> None:
140
  """Overwrite the slug field in an existing run's meta.json.
141
 
@@ -272,11 +292,11 @@ async def create_async(
272
  emit({"kind": "phase_done", "phase": "plan", "ok": plan_result.ok,
273
  "num_turns": plan_result.num_turns,
274
  "cost_usd": plan_result.total_cost_usd})
275
- plan_path = p1.path / "specs" / "implementation_plan.md"
276
  record_session_phase(run_dir, "phase1_plan", {
277
- "dir": str(p1.path), "ok": bool(plan_result.ok and plan_path.is_file()),
278
  "cost_usd": plan_result.total_cost_usd, "num_turns": plan_result.num_turns})
279
- if not plan_result.ok or not plan_path.is_file():
280
  return CreateResult(
281
  ok=False, run_dir=run_dir, plan_dir=p1.path,
282
  plan=plan_result,
@@ -442,8 +462,8 @@ async def plan_async(
442
  allow_web_tools=research_resolved.web_search,
443
  ))
444
 
445
- plan_path = run_dir / "specs" / "implementation_plan.md"
446
- if not plan_result.ok or not plan_path.is_file():
447
  return PlanResult(
448
  ok=False, run_dir=run_dir,
449
  plan=plan_result,
 
136
  return None, None
137
 
138
 
139
+ def _resolve_plan_file(run_path: Path) -> Path | None:
140
+ """Return the saved implementation plan, or None if the agent never wrote it.
141
+
142
+ Defense-in-depth for the class of bug behind #32: the plan agent is told to
143
+ save `specs/implementation_plan.md`, but `snapshot_spec` writes the filename
144
+ verbatim, so an agent that drops the extension lands at
145
+ `specs/implementation_plan`. Phase 2 reads `implementation_plan.md`, so if we
146
+ find only the extension-less file we promote it to the canonical name.
147
+ """
148
+ specs = run_path / "specs"
149
+ canonical = specs / "implementation_plan.md"
150
+ if canonical.is_file():
151
+ return canonical
152
+ extensionless = specs / "implementation_plan"
153
+ if extensionless.is_file():
154
+ extensionless.rename(canonical)
155
+ return canonical
156
+ return None
157
+
158
+
159
  def _update_run_slug(run_dir: Path, slug: str) -> None:
160
  """Overwrite the slug field in an existing run's meta.json.
161
 
 
292
  emit({"kind": "phase_done", "phase": "plan", "ok": plan_result.ok,
293
  "num_turns": plan_result.num_turns,
294
  "cost_usd": plan_result.total_cost_usd})
295
+ plan_path = _resolve_plan_file(p1.path)
296
  record_session_phase(run_dir, "phase1_plan", {
297
+ "dir": str(p1.path), "ok": bool(plan_result.ok and plan_path is not None),
298
  "cost_usd": plan_result.total_cost_usd, "num_turns": plan_result.num_turns})
299
+ if not plan_result.ok or plan_path is None:
300
  return CreateResult(
301
  ok=False, run_dir=run_dir, plan_dir=p1.path,
302
  plan=plan_result,
 
462
  allow_web_tools=research_resolved.web_search,
463
  ))
464
 
465
+ plan_path = _resolve_plan_file(run_dir)
466
+ if not plan_result.ok or plan_path is None:
467
  return PlanResult(
468
  ok=False, run_dir=run_dir,
469
  plan=plan_result,
src/autocodabench/skills/plan/SKILL.md CHANGED
@@ -26,7 +26,9 @@ which is exactly the kind of cost-burn we're trying to avoid.
26
  2. **No code, no notebook.** Don't write Python in chat code-fences,
27
  don't call any `nb_*` tool. Phase 2 writes code.
28
  3. **One artifact: `<run>/specs/implementation_plan.md`.** Save via
29
- `autocodabench_snapshot_spec(name="implementation_plan", body=<md>)`.
 
 
30
  4. **Be specific, but version-robust.** Every section must name
31
  concrete things Phase 2 can implement without asking. See the §2
32
  template — fields like "primary metric" should be
@@ -213,7 +215,7 @@ Once drafted, save:
213
 
214
  ```
215
  autocodabench_snapshot_spec(
216
- name="implementation_plan",
217
  body=<the full markdown>,
218
  )
219
 
@@ -336,7 +338,8 @@ From `autocodabench`:
336
  - `autocodabench_open_run(slug?)` — once, first tool call.
337
  - `autocodabench_current_run()` — sanity check the run dir.
338
  - `autocodabench_log_event(kind, payload?)`.
339
- - `autocodabench_snapshot_spec(name, body)` — save / revise the plan.
 
340
 
341
  **Research tools — ground the design in the existing literature and in
342
  hosting practice, not in your prior alone.** Two *structured* sources
 
26
  2. **No code, no notebook.** Don't write Python in chat code-fences,
27
  don't call any `nb_*` tool. Phase 2 writes code.
28
  3. **One artifact: `<run>/specs/implementation_plan.md`.** Save via
29
+ `autocodabench_snapshot_spec(filename="implementation_plan.md", body=<md>)`.
30
+ The argument is `filename` (NOT `name`) and it is written verbatim — it MUST
31
+ end in `.md`, or Phase 2 cannot find the plan.
32
  4. **Be specific, but version-robust.** Every section must name
33
  concrete things Phase 2 can implement without asking. See the §2
34
  template — fields like "primary metric" should be
 
215
 
216
  ```
217
  autocodabench_snapshot_spec(
218
+ filename="implementation_plan.md",
219
  body=<the full markdown>,
220
  )
221
 
 
338
  - `autocodabench_open_run(slug?)` — once, first tool call.
339
  - `autocodabench_current_run()` — sanity check the run dir.
340
  - `autocodabench_log_event(kind, payload?)`.
341
+ - `autocodabench_snapshot_spec(filename, body)` — save / revise the plan
342
+ (use `filename="implementation_plan.md"`).
343
 
344
  **Research tools — ground the design in the existing literature and in
345
  hosting practice, not in your prior alone.** Two *structured* sources
tests/test_pipeline_phases.py CHANGED
@@ -21,3 +21,62 @@ def test_bundle_async_rejects_both_sources(tmp_path):
21
  plan.write_text("# plan", encoding="utf-8")
22
  with pytest.raises(ValueError, match="exactly one"):
23
  asyncio.run(bundle_async(run_dir=tmp_path, plan_path=plan))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  plan.write_text("# plan", encoding="utf-8")
22
  with pytest.raises(ValueError, match="exactly one"):
23
  asyncio.run(bundle_async(run_dir=tmp_path, plan_path=plan))
24
+
25
+
26
+ def test_plan_prompt_save_call_matches_tool_and_pipeline():
27
+ """Regression for #32: 'plan phase finished without saving
28
+ specs/implementation_plan.md'.
29
+
30
+ The plan skill told the agent to save with
31
+ `autocodabench_snapshot_spec(name="implementation_plan", ...)` — but the tool
32
+ parameter is `filename` (no `name` alias) and `snapshot_spec` writes the
33
+ name verbatim, so the file landed at `specs/implementation_plan` (no `.md`)
34
+ or the call errored. The pipeline then looked for
35
+ `specs/implementation_plan.md` and reported the plan as never saved.
36
+
37
+ The system prompt the plan agent actually sees must therefore (a) use the
38
+ real `filename` parameter and (b) include the `.md` the pipeline checks for.
39
+ """
40
+ import inspect
41
+
42
+ from autocodabench.run_log import snapshot_spec
43
+ from autocodabench.agent.prompts import plan_system_prompt
44
+
45
+ # The save tool's first parameter is `filename` (no `name`).
46
+ assert list(inspect.signature(snapshot_spec).parameters)[0] == "filename"
47
+
48
+ prompt = plan_system_prompt()
49
+ # Must not instruct a kwarg/value that yields the wrong path.
50
+ assert 'name="implementation_plan"' not in prompt, (
51
+ "plan prompt still tells the agent to save with `name=` / without `.md`"
52
+ )
53
+ # Must instruct the exact save the pipeline can find.
54
+ assert 'filename="implementation_plan.md"' in prompt
55
+
56
+
57
+ def test_resolve_plan_file_promotes_extensionless(tmp_path):
58
+ """Defense-in-depth: a plan saved as `implementation_plan` (no `.md`) is
59
+ promoted to the canonical `implementation_plan.md` that Phase 2 reads."""
60
+ from autocodabench.agent.pipeline import _resolve_plan_file
61
+
62
+ specs = tmp_path / "specs"
63
+ specs.mkdir()
64
+ assert _resolve_plan_file(tmp_path) is None # nothing saved
65
+
66
+ (specs / "implementation_plan").write_text("# plan", encoding="utf-8")
67
+ resolved = _resolve_plan_file(tmp_path)
68
+ assert resolved == specs / "implementation_plan.md"
69
+ assert resolved.is_file()
70
+ assert not (specs / "implementation_plan").exists() # renamed, not copied
71
+
72
+
73
+ def test_resolve_plan_file_prefers_canonical(tmp_path):
74
+ from autocodabench.agent.pipeline import _resolve_plan_file
75
+
76
+ specs = tmp_path / "specs"
77
+ specs.mkdir()
78
+ (specs / "implementation_plan.md").write_text("# canonical", encoding="utf-8")
79
+ (specs / "implementation_plan").write_text("# stale", encoding="utf-8")
80
+ resolved = _resolve_plan_file(tmp_path)
81
+ assert resolved == specs / "implementation_plan.md"
82
+ assert resolved.read_text(encoding="utf-8") == "# canonical" # didn't clobber
web/.chainlit/config.toml CHANGED
@@ -132,14 +132,17 @@ cot = "tool_call"
132
 
133
  # Specify a CSS file that can be used to customize the user interface.
134
  # The CSS file can be served from the public directory or via an external link.
135
- custom_css = "/public/login.css"
 
 
 
136
 
137
  # Specify additional attributes for a custom CSS file
138
  # custom_css_attributes = "media=\"print\""
139
 
140
  # Specify a JavaScript file that can be used to customize the user interface.
141
  # The JavaScript file can be served from the public directory.
142
- custom_js = "/public/chat.js"
143
 
144
  # The style of alert boxes. Can be "classic" or "modern".
145
  alert_style = "classic"
@@ -161,7 +164,7 @@ alert_style = "classic"
161
  # custom_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png"
162
 
163
  # Load assistant logo directly from URL.
164
- logo_file_url = ""
165
 
166
  # Load assistant avatar image directly from URL.
167
  default_avatar_file_url = ""
 
132
 
133
  # Specify a CSS file that can be used to customize the user interface.
134
  # The CSS file can be served from the public directory or via an external link.
135
+ # NOTE: the ?v= query string is a cache-buster. Chainlit serves no
136
+ # Cache-Control header on /public assets, so browsers can hold a stale
137
+ # copy. Bump the version whenever login.css / chat.js change materially.
138
+ custom_css = "/public/login.css?v=15"
139
 
140
  # Specify additional attributes for a custom CSS file
141
  # custom_css_attributes = "media=\"print\""
142
 
143
  # Specify a JavaScript file that can be used to customize the user interface.
144
  # The JavaScript file can be served from the public directory.
145
+ custom_js = "/public/chat.js?v=15"
146
 
147
  # The style of alert boxes. Can be "classic" or "modern".
148
  alert_style = "classic"
 
164
  # custom_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png"
165
 
166
  # Load assistant logo directly from URL.
167
+ logo_file_url = "/public/codabench-logo.png"
168
 
169
  # Load assistant avatar image directly from URL.
170
  default_avatar_file_url = ""
web/app.py CHANGED
@@ -62,6 +62,40 @@ log = logging.getLogger("autocodabench.web")
62
  register_upload_route()
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  # ---------------------------------------------------------------------------
66
  # Auth
67
  # ---------------------------------------------------------------------------
 
62
  register_upload_route()
63
 
64
 
65
+ def _install_no_cache_for_custom_assets() -> None:
66
+ """Force browsers to always re-fetch the SPA shell + our custom JS/CSS.
67
+
68
+ Chainlit serves `/` and `/public/*` with NO Cache-Control header, so
69
+ browsers heuristically cache `chat.js` / `login.css` and keep running a
70
+ stale UI across reloads (the recurring "X is gone after refresh"). Marking
71
+ just those responses `no-store` makes every reload pick up the live files.
72
+ Added at import time, before uvicorn starts serving.
73
+ """
74
+ try:
75
+ from chainlit.server import app as cl_app
76
+ except Exception as e: # pragma: no cover
77
+ log.warning("Chainlit FastAPI app unavailable; no-cache not installed: %s", e)
78
+ return
79
+ if getattr(cl_app, "_ac_no_cache_installed", False):
80
+ return
81
+ cl_app._ac_no_cache_installed = True # type: ignore[attr-defined]
82
+
83
+ _NO_CACHE_PATHS = ("/public/chat.js", "/public/login.css")
84
+
85
+ @cl_app.middleware("http")
86
+ async def _ac_no_cache(request, call_next): # type: ignore[no-untyped-def]
87
+ response = await call_next(request)
88
+ path = request.url.path
89
+ if path == "/" or path in _NO_CACHE_PATHS:
90
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
91
+ response.headers["Pragma"] = "no-cache"
92
+ response.headers["Expires"] = "0"
93
+ return response
94
+
95
+
96
+ _install_no_cache_for_custom_assets()
97
+
98
+
99
  # ---------------------------------------------------------------------------
100
  # Auth
101
  # ---------------------------------------------------------------------------
web/chainlit.md CHANGED
@@ -1,56 +1,110 @@
1
- # AutoCodabench
2
-
3
- A scientific-friend assistant for designing and validating **Codabench** competitions.
 
 
 
4
 
5
  ---
6
 
7
- ## Pick what you want to do (at the start of every chat)
8
-
9
- When a chat begins you choose one of two paths. To switch later, start a **New Chat** and choose again.
10
-
11
- - **🛠 Create a bundle from scratch** — go through Plan → Build → Validate. Optionally drop a PDF/markdown proposal to seed the plan.
12
- - **✅ I have a bundle — validate it** — skip straight to validation. The composer locks to *attach-only*: drop your bundle `.zip` and press send.
13
-
14
- The **phase bar** at the top shows progress only (● in progress · ✓ done · ⤼ skipped). You move forward with the **▶ Proceed** buttons that appear in chat — clicking a pill just flashes this Readme.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ---
17
 
18
- ## Path A Create a bundle from scratch
19
-
20
- **1. 📝 Plan.** Chat with the agent until it converges on a one-page `implementation_plan.md` covering the 7 design sections (task, data, metric, baseline, rules, ethics, schedule). Review it in the **workspace panel** on the right. When ready, click **▶ Proceed to Phase 2**.
21
 
22
- **2. 📦 Build.** A fresh agent (no memory of the chat) reads the plan and writes the Codabench bundle — `competition.yaml`, `scoring_program/score.py`, the baseline `solution/`, and the standard pages — then validates and zips it. It also **builds and runs the bundle in Docker** using the `docker_image` from `competition.yaml` (**~5–10 min** for a verified bundle; longer on first run while the image pulls). You get a `bundle.zip` download and an **⬆️ Upload to Codabench** button. Click **▶ Proceed to Phase 3**.
23
 
24
- **3. Validate.** See below same as Path B, plus a **design scorecard** (Table A) comparing your plan against best practice.
 
 
 
25
 
26
  ---
27
 
28
- ## Path B Validate an existing bundle
29
-
30
- Attach your bundle `.zip` and press send. AutoCodabench runs the full check framework, **executing the baseline in Docker** (it pulls the `docker_image` if needed), and writes a report.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  ---
33
 
34
- ## What validation produces
35
-
36
- A **✅ PASS / ❌ FAIL** verdict in chat plus two colorful tables, and a downloadable `validation_report.md`:
37
-
38
- - **Table A — design scorecard** *(create-path only)*: each of the 7 design sections marked ✅ / ⚠️ / ❌ against best practice.
39
- - **Table B — checks**: every check with ✅ pass / ❌ fail / ⚠️ finding / 📋 attestation / • skipped.
40
- - **Gate failures (❌)** must be fixed before upload (e.g. missing `competition.yaml` keys, broken file refs, a baseline that crashes in Docker).
41
- - **Findings (⚠️)** are advisory design risks (with citations) — they don't block upload.
42
- - **Attestations (📋)** are criteria only a human can certify.
43
 
44
- After the report, you'll be offered an optional **✨ LLM-judged** pass: an LLM reads your participant-facing pages and flags **contradictions** against `competition.yaml` (e.g. a page promises a metric or submission limit the config doesn't declare). It's advisory only and needs Claude auth.
 
 
 
 
 
45
 
46
- Everything is downloadable from the workspace panel: `implementation_plan.md`, `bundle.zip`, `validation_report.md`, and a combined `workspace.zip`.
 
47
 
48
  ---
49
 
50
- ## Internal note — operator checklist
51
-
52
- (_For the project maintainer / internal testers._) This is a **private alpha**:
53
-
54
- - Logs are uploaded to a private HF Dataset (`ktgiahieu/autocodabench-runs`).
55
- - Each session has a hard Anthropic budget cap (default **$5.00**, via `MAX_USD_PER_SESSION`).
56
- - HF Spaces is CPU-only, ≤16 GB RAM; if Docker isn't available, Phase 2 skips the runtime check and Phase 3's Docker execution checks report as *skipped* rather than failing.
 
1
+ <div align="center" style="margin:6px 0 2px">
2
+ <img src="/public/codabench-logo.png" alt="Codabench" style="height:40px" />
3
+ <div style="color:hsl(var(--muted-foreground));font-size:14px;margin-top:6px">
4
+ Design &amp; validate Codabench competitions — just by chatting.
5
+ </div>
6
+ </div>
7
 
8
  ---
9
 
10
+ ### 🧭 The three phases
11
+
12
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin:8px 0 4px">
13
+ <div style="flex:1;min-width:150px;padding:14px;border:1px solid hsl(var(--border));border-radius:12px;background:hsl(var(--accent))">
14
+ <div style="font-size:24px;line-height:1">📝</div>
15
+ <div style="font-weight:700;margin-top:6px">1 · Plan</div>
16
+ <div style="font-size:12.5px;color:hsl(var(--muted-foreground));margin-top:3px">Chat an idea into a design plan.</div>
17
+ </div>
18
+ <div style="flex:1;min-width:150px;padding:14px;border:1px solid hsl(var(--border));border-radius:12px;background:hsl(var(--accent))">
19
+ <div style="font-size:24px;line-height:1">📦</div>
20
+ <div style="font-weight:700;margin-top:6px">2 · Build</div>
21
+ <div style="font-size:12.5px;color:hsl(var(--muted-foreground));margin-top:3px">Agent writes the bundle &amp; runs it.</div>
22
+ </div>
23
+ <div style="flex:1;min-width:150px;padding:14px;border:1px solid hsl(var(--border));border-radius:12px;background:hsl(var(--accent))">
24
+ <div style="font-size:24px;line-height:1">✅</div>
25
+ <div style="font-weight:700;margin-top:6px">3 · Validate</div>
26
+ <div style="font-size:12.5px;color:hsl(var(--muted-foreground));margin-top:3px">Checks + report: ready to launch?</div>
27
+ </div>
28
+ </div>
29
+
30
+ The **phase bar** (top-right) shows where you are — `●` active · `✓` done.
31
+ Hover any phase for a one-line reminder. You move forward with the **▶ Proceed**
32
+ buttons in the chat.
33
 
34
  ---
35
 
36
+ ### 🚀 Two ways to start
 
 
37
 
38
+ Every new chat opens with a choice (switch later via **New Chat**, top-left):
39
 
40
+ - **🛠 Create from scratch** — you have an *idea*. Go through all three phases below.
41
+ - **✅ Validate a bundle** — you already have a `.zip`. Jump straight to **Phase 3**;
42
+ the composer locks to *attach-only* (drop your bundle and send). No bundle? That
43
+ screen has a **⬇ Download example bundle** button to try.
44
 
45
  ---
46
 
47
+ ## 📝 Phase 1 · Plan
48
+
49
+ *Design the competition together the agent acts as a scientific collaborator.*
50
+
51
+ - **You provide:** a one-sentence idea — e.g. *“a fair chest-X-ray pneumonia
52
+ challenge, scored by balanced accuracy.”* You can also **drop a PDF or markdown
53
+ proposal** and it'll seed the plan.
54
+ - **What happens:** the agent researches related work (OpenAlex / Kaggle), then
55
+ works through the **7 design sections** with you — *task, data, metric, baseline,
56
+ rules, ethics, schedule* — asking questions until the design is coherent.
57
+ - **You get:** a one-page **`implementation_plan.md`** in the **workspace panel**
58
+ on the right. Read it, push back, refine.
59
+ - **Next:** when it looks right, click **▶ Proceed to Phase 2**.
60
+
61
+ ## 📦 Phase 2 · Competition Creation (Build)
62
+
63
+ *A fresh agent turns the approved plan into a real Codabench bundle.*
64
+
65
+ - **Input:** **only** the locked `implementation_plan.md` — this agent never sees
66
+ your chat, which keeps the build honest (no leaked answers).
67
+ - **What happens:** it writes the full bundle — `competition.yaml`, the
68
+ `scoring_program/`, a **baseline solution**, the ingestion program, participant
69
+ **pages**, and a **starting kit** — then **builds and runs it in Docker** (using
70
+ the bundle's `docker_image`) to prove the baseline actually produces a score.
71
+ Expect **~5–10 min** (longer the first time, while the image downloads).
72
+ - **You get:** a **`bundle.zip`** to download, plus an **⬆️ Upload to Codabench**
73
+ button.
74
+ - **Next:** click **▶ Proceed to Phase 3**.
75
+
76
+ ## ✅ Phase 3 · Validation
77
+
78
+ *The pre-launch safety check — would this competition run cleanly?*
79
+
80
+ - **Input:** the bundle — automatically from Phase 2, or (Validate path) the
81
+ `.zip` you **attach**.
82
+ - **What happens:** runs the full **check framework** — schema and file-reference
83
+ checks, plus a real **Docker execution of the baseline** — and, on the create
84
+ path, a **design scorecard** grading your plan against best practice. You can
85
+ then run an optional **✨ LLM-judged** pass that flags participant pages
86
+ contradicting `competition.yaml`.
87
+ - **You get:** a **✅ PASS / ❌ FAIL** verdict, two result tables, and a
88
+ downloadable **`validation_report.md`**.
89
+ - **Next:** fix any **❌** gate failures and re-validate; **⚠️** findings are
90
+ advisory.
91
 
92
  ---
93
 
94
+ ### 📊 How to read the report
 
 
 
 
 
 
 
 
95
 
96
+ | Badge | Meaning |
97
+ |---|---|
98
+ | ❌ **Gate failure** | **Must fix before upload** — bad config, broken file paths, a baseline that crashes in Docker. |
99
+ | ⚠️ **Finding** | Advisory design risk, with a citation. Doesn't block upload. |
100
+ | 📋 **Attestation** | A criterion only a human can certify. |
101
+ | • **Skipped** | Couldn't run here (e.g. no Docker) — not a failure. |
102
 
103
+ Everything downloads from the **workspace panel**: the plan, `bundle.zip`,
104
+ `validation_report.md`, and a combined `workspace.zip`.
105
 
106
  ---
107
 
108
+ <div style="font-size:12px;color:hsl(var(--muted-foreground))">
109
+ ⚙ Change the model &amp; theme from <b>settings</b> (top-right) · 💸 each session has a spend cap (default $5) · 🐳 without Docker, baseline-execution checks are skipped, not failed.
110
+ </div>
 
 
 
 
web/public/avatars/autocodabench.png ADDED
web/public/chat.js CHANGED
@@ -35,31 +35,40 @@
35
 
36
  const INIT_BANNER_HTML = `
37
  <span class="ac-init-spinner" aria-hidden="true"></span>
38
- <span>
39
- <b>Initializing AutoCodabench…</b>
40
- spinning up MCP tool servers and the literature index — this takes
41
- up to 30s on first connect. Chat input is locked until ready.
42
- </span>
43
  `;
44
 
 
 
 
 
 
 
 
 
 
45
  // ---------------------------------------------------------------
46
  // (4) Init banner + input lock, applied from the *very first*
47
  // paint so the user never sees an unlocked chat.
48
  //
49
- // We gate the lock on `textarea` existing — the login page has no
50
- // textarea, so the banner only shows after the user has signed in
51
- // and the chat surface is visible.
52
  //
53
- // We unlock as soon as READY_PHRASE appears in the body's text.
 
 
 
 
54
  // ---------------------------------------------------------------
55
  function syncInitGate() {
56
  const onChatPage = !!document.querySelector("textarea");
57
  const bodyText = document.body.textContent;
58
- const isReady = READY_PHRASES.some((p) => bodyText.includes(p));
 
59
 
60
  // -- banner --
61
  let banner = document.getElementById("ac-init-banner");
62
- if (onChatPage && !isReady) {
63
  if (!banner) {
64
  banner = document.createElement("div");
65
  banner.id = "ac-init-banner";
@@ -72,7 +81,7 @@
72
 
73
  if (!onChatPage) return; // login page: skip input lock too
74
 
75
- const locked = !isReady;
76
 
77
  // -- textarea --
78
  document.querySelectorAll("textarea").forEach((el) => {
@@ -224,6 +233,82 @@
224
  setTimeout(() => btn.classList.remove("ac-readme-flash"), 3200);
225
  }
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  async function _refreshPhasePillsFromState() {
228
  const sid = _currentSessionId();
229
  if (!sid) return;
@@ -238,6 +323,9 @@
238
  if (!r.ok) return;
239
  const state = await r.json();
240
 
 
 
 
241
  // Cache the input mode for syncInputMode() (attach-only lock).
242
  window.__acInputMode = state.input_mode || "normal";
243
 
@@ -246,7 +334,9 @@
246
  const sig = JSON.stringify({
247
  cur: state.current,
248
  mode: window.__acInputMode,
249
- items: (state.phases || []).map((x) => [x.id, x.status]),
 
 
250
  });
251
  if (sig === _lastPhasePillsSig) return;
252
  _lastPhasePillsSig = sig;
@@ -258,21 +348,52 @@
258
  skipped: "Skipped (you started later in the pipeline)",
259
  pending: "Upcoming",
260
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  pillsHost.innerHTML = "";
262
- (state.phases || []).forEach((ph, idx) => {
263
  const pill = document.createElement("span");
264
  pill.className = "ac-pp ac-pp-" + ph.status;
265
  pill.dataset.phaseId = ph.id;
266
  pill.dataset.phaseStatus = ph.status;
267
- pill.textContent = `${idx + 1}. ${ph.title}${ICON[ph.status] || ""}`;
268
- if (ph.status === "active") {
269
- pill.title = TIP.active;
270
- } else {
271
- // Pills are progress-only — you advance via the in-chat
272
- // "Proceed" buttons, not by clicking pills. Clicking one
273
- // flashes the Readme so the user learns how the bar works.
274
- pill.title = (TIP[ph.status] || "") +
275
- " use the Proceed buttons in chat to move between phases";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  pill.classList.add("ac-pp-hint");
277
  pill.addEventListener("click", _flashReadmeForHelp);
278
  }
@@ -308,7 +429,11 @@
308
  // stale input_mode (the bug where the validate-mode lock leaks across
309
  // New Chat, or fails to apply). On change we drop per-session caches.
310
  const text = document.body.textContent || "";
311
- const matches = text.match(/session\s+`?[a-f0-9]{8,16}`?/g);
 
 
 
 
312
  let sid = null;
313
  if (matches && matches.length) {
314
  const m = matches[matches.length - 1].match(/([a-f0-9]{8,16})/);
@@ -327,6 +452,9 @@
327
  _lastDownloadsSig = "";
328
  for (const k in _lastTagByUrl) delete _lastTagByUrl[k];
329
  window.__acPendingModeFetch = true;
 
 
 
330
  }
331
  return window.__acSessionId || null;
332
  }
@@ -664,6 +792,219 @@
664
  if (btn.innerHTML !== expected) btn.innerHTML = expected;
665
  }
666
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
  function tick() {
668
  syncInitGate(); // run first so the lock is up before anything else
669
  _currentSessionId(); // detect New-Chat session swap early (resets caches)
@@ -676,6 +1017,8 @@
676
  syncInputMode(); // apply the attach-only / locked composer mode
677
  _injectSidePanel(); // sci-space-style persistent workspace panel
678
  _ensurePhasePills(); // header-row phase pills (slim, no chips)
 
 
679
  // syncFilesToggle is now redundant — the persistent panel is
680
  // the primary file viewer. Keep the function around for the
681
  // edge case where the panel can't materialise (no session id).
 
35
 
36
  const INIT_BANNER_HTML = `
37
  <span class="ac-init-spinner" aria-hidden="true"></span>
38
+ <span><b>Starting up…</b> connecting tools — this takes a few seconds.</span>
 
 
 
 
39
  `;
40
 
41
+ // Chooser is on screen once the entry cards are tagged (or its heading
42
+ // text is present). The server runs the MCP probe BEFORE showing the
43
+ // chooser, so by the time it appears initialization is actually done —
44
+ // the banner should be gone even though no greeting phrase exists yet.
45
+ function _chooserShown(bodyText) {
46
+ return !!document.querySelector("button[data-ac-entry]") ||
47
+ bodyText.includes("Choose how you'd like to start");
48
+ }
49
+
50
  // ---------------------------------------------------------------
51
  // (4) Init banner + input lock, applied from the *very first*
52
  // paint so the user never sees an unlocked chat.
53
  //
54
+ // We gate on `textarea` existing — the login page has no textarea, so the
55
+ // banner only shows after sign-in.
 
56
  //
57
+ // Banner: shows ONLY while genuinely initializing i.e. before EITHER the
58
+ // chooser appears (MCP probe done) OR a greeting lands. It used to persist
59
+ // through the whole chooser because it only watched the greeting phrases.
60
+ // Input lock: stays on until a greeting lands, so on the landing the user
61
+ // picks via the cards rather than typing.
62
  // ---------------------------------------------------------------
63
  function syncInitGate() {
64
  const onChatPage = !!document.querySelector("textarea");
65
  const bodyText = document.body.textContent;
66
+ const greetingReady = READY_PHRASES.some((p) => bodyText.includes(p));
67
+ const initializing = onChatPage && !greetingReady && !_chooserShown(bodyText);
68
 
69
  // -- banner --
70
  let banner = document.getElementById("ac-init-banner");
71
+ if (initializing) {
72
  if (!banner) {
73
  banner = document.createElement("div");
74
  banner.id = "ac-init-banner";
 
81
 
82
  if (!onChatPage) return; // login page: skip input lock too
83
 
84
+ const locked = !greetingReady;
85
 
86
  // -- textarea --
87
  document.querySelectorAll("textarea").forEach((el) => {
 
233
  setTimeout(() => btn.classList.remove("ac-readme-flash"), 3200);
234
  }
235
 
236
+ // ---- phase pill: custom instant tooltip + click-to-advance ----
237
+ function _acPillTipEl() {
238
+ let t = document.getElementById("ac-pill-tip");
239
+ if (!t) {
240
+ t = document.createElement("div");
241
+ t.id = "ac-pill-tip";
242
+ t.hidden = true;
243
+ document.body.appendChild(t);
244
+ }
245
+ return t;
246
+ }
247
+ function _acShowPillTip(pill) {
248
+ const t = _acPillTipEl();
249
+ t.textContent = pill.dataset.tip || "";
250
+ t.hidden = false;
251
+ const r = pill.getBoundingClientRect();
252
+ const w = t.offsetWidth || 240;
253
+ let left = r.left + r.width / 2 - w / 2;
254
+ left = Math.max(8, Math.min(left, window.innerWidth - w - 8));
255
+ t.style.left = `${Math.round(left)}px`;
256
+ t.style.top = `${Math.round(r.bottom + 8)}px`;
257
+ }
258
+ function _acHidePillTip() {
259
+ const t = document.getElementById("ac-pill-tip");
260
+ if (t) t.hidden = true;
261
+ }
262
+ // Advance by click-simulating the in-chat "▶ Proceed to Phase N" button.
263
+ function _acAdvanceToPhase(num) {
264
+ const wanted = `Proceed to Phase ${num}`;
265
+ for (const b of document.querySelectorAll("button, a")) {
266
+ if ((b.textContent || "").includes(wanted)) {
267
+ try { b.scrollIntoView({block: "center"}); } catch (e) {}
268
+ try { b.click(); } catch (e) {}
269
+ return;
270
+ }
271
+ }
272
+ _flashReadmeForHelp(); // button not present yet — nudge to the Readme
273
+ }
274
+
275
+ // ---------------------------------------------------------------
276
+ // Persistent cost / context widget (bottom-left). Fed by the
277
+ // phase_state.json poll (state.cost + state.context), refreshed by
278
+ // the server after every turn — so the session spend and context use
279
+ // are ALWAYS visible instead of buried in a per-turn footer line.
280
+ // ---------------------------------------------------------------
281
+ function _renderCostWidget(state) {
282
+ if (!document.querySelector("textarea")) return; // login page
283
+ const cost = state && state.cost;
284
+ const ctx = state && state.context;
285
+ if (!cost) return;
286
+ let w = document.getElementById("ac-cost-widget");
287
+ if (!w) {
288
+ w = document.createElement("div");
289
+ w.id = "ac-cost-widget";
290
+ w.innerHTML =
291
+ '<div class="ac-cost-top">' +
292
+ '<span class="ac-cost-budget"></span>' +
293
+ '<span class="ac-cost-bar"><i></i></span>' +
294
+ '</div>' +
295
+ '<div class="ac-cost-ctx"></div>';
296
+ document.body.appendChild(w);
297
+ }
298
+ const usd = cost.cumulative_usd || 0;
299
+ const budget = cost.budget_usd || 0;
300
+ const pct = Math.max(0, Math.min(100, cost.pct || 0));
301
+ const ctxPct = ctx ? (ctx.pct || 0) : 0;
302
+ w.querySelector(".ac-cost-budget").textContent =
303
+ `💰 $${usd.toFixed(2)} / $${budget.toFixed(2)}`;
304
+ const bar = w.querySelector(".ac-cost-bar > i");
305
+ bar.style.width = pct + "%";
306
+ bar.style.background = pct > 85 ? "hsl(0 72% 52%)"
307
+ : pct > 60 ? "hsl(38 92% 50%)" : "hsl(var(--primary))";
308
+ w.querySelector(".ac-cost-ctx").textContent =
309
+ `🧠 ${ctxPct.toFixed(1)}% context`;
310
+ }
311
+
312
  async function _refreshPhasePillsFromState() {
313
  const sid = _currentSessionId();
314
  if (!sid) return;
 
323
  if (!r.ok) return;
324
  const state = await r.json();
325
 
326
+ // Always-visible cost / context widget.
327
+ _renderCostWidget(state);
328
+
329
  // Cache the input mode for syncInputMode() (attach-only lock).
330
  window.__acInputMode = state.input_mode || "normal";
331
 
 
334
  const sig = JSON.stringify({
335
  cur: state.current,
336
  mode: window.__acInputMode,
337
+ // include `exists` the advance pill turns on when the active
338
+ // phase's artifact appears, so a status-only sig would miss it.
339
+ items: (state.phases || []).map((x) => [x.id, x.status, x.exists]),
340
  });
341
  if (sig === _lastPhasePillsSig) return;
342
  _lastPhasePillsSig = sig;
 
348
  skipped: "Skipped (you started later in the pipeline)",
349
  pending: "Upcoming",
350
  };
351
+ // 1–2 sentence explanation of each phase, shown on hover. Keyed by
352
+ // phase id (config.py: plan / bundle / validate).
353
+ const PHASE_DESC = {
354
+ plan: "Shape your idea into a one-page competition plan "
355
+ + "(task, data, metric, baseline, rules, ethics, schedule).",
356
+ bundle: "A fresh agent turns the plan into a full Codabench "
357
+ + "bundle and runs the baseline in Docker to prove it works.",
358
+ validate: "Run the checks plus a Docker baseline execution, then "
359
+ + "get a PASS/FAIL report.",
360
+ };
361
+ const phases = state.phases || [];
362
+ // The next phase becomes enterable once the active phase's artifact
363
+ // exists (e.g. the plan is saved → you can proceed to Phase 2).
364
+ const activeIdx = phases.findIndex((p) => p.status === "active");
365
+ const advanceIdx = (activeIdx >= 0 && phases[activeIdx].exists)
366
+ ? activeIdx + 1 : -1;
367
+
368
  pillsHost.innerHTML = "";
369
+ phases.forEach((ph, idx) => {
370
  const pill = document.createElement("span");
371
  pill.className = "ac-pp ac-pp-" + ph.status;
372
  pill.dataset.phaseId = ph.id;
373
  pill.dataset.phaseStatus = ph.status;
374
+ const isAdvance = idx === advanceIdx;
375
+ pill.textContent = `${idx + 1}. ${ph.title}`
376
+ + (isAdvance ? " ▸" : (ICON[ph.status] || ""));
377
+ const desc = PHASE_DESC[ph.id] || ph.title;
378
+
379
+ // Custom instant tooltip (native `title` is delayed/unreliable).
380
+ pill.dataset.tip = isAdvance
381
+ ? `${desc}\n\n▸ Click to proceed to ${ph.title}.`
382
+ : (ph.status === "active"
383
+ ? `${desc}\n\n● In progress.`
384
+ : `${desc}\n\n${TIP[ph.status] || "Upcoming"}.`);
385
+ pill.addEventListener("mouseenter", () => _acShowPillTip(pill));
386
+ pill.addEventListener("mouseleave", _acHidePillTip);
387
+
388
+ if (isAdvance) {
389
+ // Clickable: proceed to this phase.
390
+ pill.classList.add("ac-pp-advance");
391
+ pill.addEventListener("click", () => {
392
+ _acHidePillTip();
393
+ _acAdvanceToPhase(idx + 1);
394
+ });
395
+ } else if (ph.status !== "active") {
396
+ // Progress-only — clicking nudges the Readme.
397
  pill.classList.add("ac-pp-hint");
398
  pill.addEventListener("click", _flashReadmeForHelp);
399
  }
 
429
  // stale input_mode (the bug where the validate-mode lock leaks across
430
  // New Chat, or fails to apply). On change we drop per-session caches.
431
  const text = document.body.textContent || "";
432
+ // `\s*` (not `\s+`): the greeting footer renders the id in a chip as
433
+ // "session" + "<hex>" with no whitespace between the two elements, so
434
+ // textContent reads "session1dffc8eeffae". Older "session `hex`" prose
435
+ // still matches too.
436
+ const matches = text.match(/session\s*`?[a-f0-9]{8,16}`?/g);
437
  let sid = null;
438
  if (matches && matches.length) {
439
  const m = matches[matches.length - 1].match(/([a-f0-9]{8,16})/);
 
452
  _lastDownloadsSig = "";
453
  for (const k in _lastTagByUrl) delete _lastTagByUrl[k];
454
  window.__acPendingModeFetch = true;
455
+ // Reset the cost widget — it re-populates from the new session's
456
+ // first phase_state poll.
457
+ document.getElementById("ac-cost-widget")?.remove();
458
  }
459
  return window.__acSessionId || null;
460
  }
 
792
  if (btn.innerHTML !== expected) btn.innerHTML = expected;
793
  }
794
 
795
+ // ---------------------------------------------------------------
796
+ // (8) Top-right Settings menu.
797
+ //
798
+ // Chainlit ships two separate header controls: a standalone theme
799
+ // toggle (#theme-toggle) and an avatar/user menu (#user-nav-button
800
+ // → Settings + Logout). We fold both into ONE gear dropdown so the
801
+ // top-right has a single, tidy settings affordance with:
802
+ // - a Light / Dark / System segmented switch, and
803
+ // - a Logout row.
804
+ // The natives are hidden via login.css. Theme is applied by
805
+ // mirroring Chainlit's own mechanism (toggle `dark` on <html> +
806
+ // persist localStorage "theme"), verified to stick without a reload.
807
+ // ---------------------------------------------------------------
808
+
809
+ const AC_ICON = {
810
+ gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
811
+ sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4"/></svg>',
812
+ moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
813
+ system: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>',
814
+ logout: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>',
815
+ readme: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
816
+ };
817
+
818
+ function _acCurrentTheme() {
819
+ const v = localStorage.getItem("theme");
820
+ return (v === "light" || v === "dark" || v === "system") ? v : "system";
821
+ }
822
+
823
+ function _acApplyTheme(mode) {
824
+ localStorage.setItem("theme", mode);
825
+ const dark = mode === "dark" ||
826
+ (mode === "system" &&
827
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
828
+ document.documentElement.classList.toggle("dark", dark);
829
+ // Reflect selection in the segmented control.
830
+ document.querySelectorAll(".ac-theme-opt").forEach((o) => {
831
+ o.classList.toggle("ac-active", o.dataset.mode === mode);
832
+ });
833
+ }
834
+
835
+ function _acLogout() {
836
+ // POST /logout clears the httpOnly auth cookie server-side; then
837
+ // we bounce to /login (requireLogin forces a fresh sign-in).
838
+ fetch("/logout", {method: "POST", credentials: "same-origin"})
839
+ .catch(() => {})
840
+ .finally(() => { window.location.href = "/login"; });
841
+ }
842
+
843
+ function _acCloseSettingsMenu() {
844
+ const menu = document.getElementById("ac-settings-menu");
845
+ const btn = document.getElementById("ac-settings-btn");
846
+ if (menu) menu.hidden = true;
847
+ if (btn) btn.setAttribute("aria-expanded", "false");
848
+ }
849
+
850
+ function _acPositionSettingsMenu() {
851
+ const menu = document.getElementById("ac-settings-menu");
852
+ const btn = document.getElementById("ac-settings-btn");
853
+ if (!menu || !btn || menu.hidden) return;
854
+ const r = btn.getBoundingClientRect();
855
+ const w = menu.offsetWidth || 248;
856
+ let left = r.right - w; // right-align to the gear
857
+ left = Math.max(8, Math.min(left, window.innerWidth - w - 8));
858
+ menu.style.top = `${Math.round(r.bottom + 8)}px`;
859
+ menu.style.left = `${Math.round(left)}px`;
860
+ }
861
+
862
+ function _acFillUserName() {
863
+ // Best-effort: show who's signed in. Cached after first fetch.
864
+ if (window.__acUserFetched) return;
865
+ window.__acUserFetched = true;
866
+ fetch("/user", {credentials: "same-origin"})
867
+ .then((r) => (r.ok ? r.json() : null))
868
+ .then((u) => {
869
+ if (!u) return;
870
+ const name = u.display_name || u.identifier || "Signed in";
871
+ const nameEl = document.querySelector(".ac-set-user-name");
872
+ const avEl = document.querySelector(".ac-set-avatar");
873
+ if (nameEl) nameEl.textContent = name;
874
+ if (avEl) avEl.textContent = (name[0] || "?").toUpperCase();
875
+ })
876
+ .catch(() => {});
877
+ }
878
+
879
+ function _ensureSettingsMenu() {
880
+ if (!document.querySelector("textarea")) return; // login page
881
+
882
+ // (a) the dropdown card — mounted once on <body> so header
883
+ // re-renders never blow it away.
884
+ let menu = document.getElementById("ac-settings-menu");
885
+ if (!menu) {
886
+ const cur = _acCurrentTheme();
887
+ const opt = (mode, icon, label) =>
888
+ `<button type="button" class="ac-theme-opt${mode === cur ? " ac-active" : ""}" ` +
889
+ `data-mode="${mode}" title="${label} theme">${icon}<span>${label}</span></button>`;
890
+ menu = document.createElement("div");
891
+ menu.id = "ac-settings-menu";
892
+ menu.hidden = true;
893
+ menu.innerHTML =
894
+ `<div class="ac-set-user">` +
895
+ `<span class="ac-set-avatar">?</span>` +
896
+ `<span class="ac-set-user-meta">` +
897
+ `<span class="ac-set-user-name">Signed in</span>` +
898
+ `<span class="ac-set-user-sub">AutoCodabench</span>` +
899
+ `</span>` +
900
+ `</div>` +
901
+ `<div class="ac-set-label">Appearance</div>` +
902
+ `<div class="ac-theme-seg">` +
903
+ opt("light", AC_ICON.sun, "Light") +
904
+ opt("dark", AC_ICON.moon, "Dark") +
905
+ opt("system", AC_ICON.system, "System") +
906
+ `</div>` +
907
+ `<div class="ac-set-sep"></div>` +
908
+ `<button type="button" class="ac-set-row" id="ac-readme-row">` +
909
+ `${AC_ICON.readme}<span>Readme</span></button>` +
910
+ `<button type="button" class="ac-set-row ac-set-danger" id="ac-logout-row">` +
911
+ `${AC_ICON.logout}<span>Log out</span></button>`;
912
+ document.body.appendChild(menu);
913
+
914
+ menu.querySelectorAll(".ac-theme-opt").forEach((o) => {
915
+ o.addEventListener("click", (e) => {
916
+ e.stopPropagation();
917
+ _acApplyTheme(o.dataset.mode);
918
+ });
919
+ });
920
+ // Readme: delegate to the native (now hidden) Readme button.
921
+ menu.querySelector("#ac-readme-row").addEventListener("click", (e) => {
922
+ e.stopPropagation();
923
+ _acCloseSettingsMenu();
924
+ const rb = document.getElementById("readme-button");
925
+ if (rb) rb.click();
926
+ });
927
+ menu.querySelector("#ac-logout-row")
928
+ .addEventListener("click", (e) => { e.stopPropagation(); _acLogout(); });
929
+ // Keep clicks inside the menu from bubbling to the document
930
+ // close-handler.
931
+ menu.addEventListener("click", (e) => e.stopPropagation());
932
+ _acFillUserName();
933
+ // Sync the active indicator to the live theme on first build.
934
+ _acApplyTheme(_acCurrentTheme());
935
+ }
936
+
937
+ // (b) the gear button. Anchor it right AFTER the phase pills when they
938
+ // exist — the user always sees the pills, so that's the most
939
+ // reliable spot and puts Settings next to the phase bar. On the
940
+ // landing (no pills yet) fall back to just before the hidden Readme
941
+ // button. Re-placed whenever Chainlit's re-render moves things.
942
+ const pills = document.getElementById("ac-phase-pills");
943
+ const readme = _findReadmeButton();
944
+ if (pills || (readme && readme.parentElement)) {
945
+ let btn = document.getElementById("ac-settings-btn");
946
+ if (!btn) {
947
+ btn = document.createElement("button");
948
+ btn.id = "ac-settings-btn";
949
+ btn.type = "button";
950
+ btn.setAttribute("aria-label", "Settings");
951
+ btn.setAttribute("aria-haspopup", "menu");
952
+ btn.setAttribute("aria-expanded", "false");
953
+ btn.title = "Settings";
954
+ btn.innerHTML = AC_ICON.gear;
955
+ btn.addEventListener("click", (e) => {
956
+ e.stopPropagation();
957
+ const m = document.getElementById("ac-settings-menu");
958
+ if (!m) return;
959
+ const willOpen = m.hidden;
960
+ m.hidden = !willOpen;
961
+ btn.setAttribute("aria-expanded", String(willOpen));
962
+ if (willOpen) { _acFillUserName(); _acPositionSettingsMenu(); }
963
+ });
964
+ }
965
+ if (pills) {
966
+ // BEFORE the pills (to their left) — keeps the gear clear of
967
+ // the fixed workspace sliver that hugs the right edge and would
968
+ // otherwise cover it.
969
+ if (pills.previousElementSibling !== btn) {
970
+ pills.insertAdjacentElement("beforebegin", btn);
971
+ }
972
+ } else if (readme.previousElementSibling !== btn) {
973
+ readme.parentElement.insertBefore(btn, readme);
974
+ }
975
+ }
976
+
977
+ // One-time global wiring: outside-click + Escape close, reposition.
978
+ if (!window.__acSettingsWired) {
979
+ window.__acSettingsWired = true;
980
+ document.addEventListener("click", () => _acCloseSettingsMenu());
981
+ document.addEventListener("keydown", (e) => {
982
+ if (e.key === "Escape") _acCloseSettingsMenu();
983
+ });
984
+ window.addEventListener("resize", _acPositionSettingsMenu);
985
+ window.addEventListener("scroll", _acPositionSettingsMenu, true);
986
+ }
987
+ }
988
+
989
+ // ---------------------------------------------------------------
990
+ // (9) Landing chooser cards.
991
+ //
992
+ // The two entry options are cl.Action buttons (server: _ask_entry_mode).
993
+ // We tag each by its (clean, fixed) label so login.css can render them
994
+ // as big product-style cards with an icon + description, instead of the
995
+ // default emoji chat buttons. Idempotent; safe to run every tick.
996
+ // ---------------------------------------------------------------
997
+ const _AC_ENTRY = {
998
+ "Create from scratch": "create",
999
+ "Validate a bundle": "validate",
1000
+ };
1001
+ function _tagEntryCards() {
1002
+ document.querySelectorAll("button").forEach((el) => {
1003
+ const mode = _AC_ENTRY[(el.textContent || "").trim()];
1004
+ if (mode && el.dataset.acEntry !== mode) el.dataset.acEntry = mode;
1005
+ });
1006
+ }
1007
+
1008
  function tick() {
1009
  syncInitGate(); // run first so the lock is up before anything else
1010
  _currentSessionId(); // detect New-Chat session swap early (resets caches)
 
1017
  syncInputMode(); // apply the attach-only / locked composer mode
1018
  _injectSidePanel(); // sci-space-style persistent workspace panel
1019
  _ensurePhasePills(); // header-row phase pills (slim, no chips)
1020
+ _ensureSettingsMenu(); // top-right gear: theme switch + logout
1021
+ _tagEntryCards(); // landing: tag the two entry options as cards
1022
  // syncFilesToggle is now redundant — the persistent panel is
1023
  // the primary file viewer. Keep the function around for the
1024
  // edge case where the panel can't materialise (no session id).
web/public/codabench-logo.png ADDED
web/public/examples/example-bundle-survival.zip ADDED
Binary file (82.5 kB). View file
 
web/public/login.css CHANGED
@@ -60,16 +60,18 @@ form:has(input[type="password"]):not(.ac-pub-form) > *:has(> button[type="submit
60
  display: flex;
61
  align-items: center;
62
  justify-content: center;
63
- gap: 12px;
64
- padding: 14px 20px;
65
- background: linear-gradient(90deg, #2563eb 0%, #7c3aed 100%);
66
- color: #fff;
 
 
 
67
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
68
  Roboto, Helvetica, Arial, sans-serif;
69
- font-size: 14px;
70
  line-height: 1.4;
71
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
72
- text-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
73
  /* Slide-down on first appearance so it doesn't feel jarring. */
74
  animation: ac-banner-in 0.25s ease-out;
75
  }
@@ -78,14 +80,15 @@ form:has(input[type="password"]):not(.ac-pub-form) > *:has(> button[type="submit
78
  }
79
  #ac-init-banner b {
80
  font-weight: 600;
 
81
  }
82
 
83
  .ac-init-spinner {
84
  flex: 0 0 auto;
85
- width: 16px;
86
- height: 16px;
87
- border: 2px solid rgba(255, 255, 255, 0.35);
88
- border-top-color: #fff;
89
  border-radius: 50%;
90
  animation: ac-spin 0.9s linear infinite;
91
  }
@@ -106,97 +109,6 @@ textarea.ac-input-locked::placeholder {
106
  }
107
 
108
 
109
- /* ============================================================
110
- * Phase-switch action buttons.
111
- *
112
- * We can't target our cl.Action buttons by a stable class (Chainlit
113
- * doesn't expose one), so we match by visible label text — the strings
114
- * are fixed server-side in app.py's _on_switch_to_impl / etc. handlers.
115
- * `:has()` is supported in every evergreen browser as of 2023.
116
- *
117
- * Three buttons styled:
118
- * - "START IMPLEMENTATION (irreversible)" — big, orange/red, pulses.
119
- * - "YES — switch to IMPLEMENTATION" — large, red, no pulse.
120
- * - "Cancel — keep planning" — large, calm grey.
121
- * ============================================================ */
122
-
123
- button:has(span:is(:contains)),
124
- button[aria-label*="START IMPLEMENTATION" i],
125
- button:has(*:not(script):not(style)) {
126
- /* The :has(:contains) selector isn't standard, so we fall through
127
- and rely on the `text starts with` JS tagger in chat.js — see
128
- below. The selector above is a no-op safety net for browsers
129
- that try to parse it; the real styling targets data attributes. */
130
- }
131
-
132
- /* Tagged by chat.js: a Chainlit action button whose label starts with
133
- * "🛠 START IMPLEMENTATION". Pulsing border to drag the eye. */
134
- button[data-ac-impl-button="primary"] {
135
- display: inline-flex !important;
136
- align-items: center;
137
- justify-content: center;
138
- min-height: 56px;
139
- padding: 14px 28px !important;
140
- margin: 12px 0 !important;
141
- font-size: 17px !important;
142
- font-weight: 700 !important;
143
- letter-spacing: 0.3px;
144
- color: #fff !important;
145
- background: linear-gradient(90deg, #d97706 0%, #dc2626 100%) !important;
146
- border: 2px solid #fff !important;
147
- border-radius: 10px !important;
148
- box-shadow:
149
- 0 0 0 0 rgba(220, 38, 38, 0.6),
150
- 0 4px 18px rgba(220, 38, 38, 0.35) !important;
151
- cursor: pointer !important;
152
- animation: ac-pulse-cta 1.6s ease-in-out infinite;
153
- }
154
- button[data-ac-impl-button="primary"]:hover {
155
- filter: brightness(1.05);
156
- transform: translateY(-1px);
157
- }
158
- @keyframes ac-pulse-cta {
159
- 0%, 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.6),
160
- 0 4px 18px rgba(220, 38, 38, 0.35); }
161
- 50% { box-shadow: 0 0 0 12px rgba(220, 38, 38, 0.0),
162
- 0 6px 22px rgba(220, 38, 38, 0.45); }
163
- }
164
-
165
- /* The final-confirm "YES" button — same loud styling, no pulse (user
166
- * has already seen the pulse; we want them deliberate now). */
167
- button[data-ac-impl-button="confirm"] {
168
- display: inline-flex !important;
169
- align-items: center;
170
- justify-content: center;
171
- min-height: 52px;
172
- padding: 12px 26px !important;
173
- margin: 10px 8px 10px 0 !important;
174
- font-size: 16px !important;
175
- font-weight: 700 !important;
176
- color: #fff !important;
177
- background: #dc2626 !important;
178
- border: 2px solid #991b1b !important;
179
- border-radius: 8px !important;
180
- box-shadow: 0 4px 14px rgba(220, 38, 38, 0.30) !important;
181
- cursor: pointer !important;
182
- }
183
- button[data-ac-impl-button="cancel"] {
184
- display: inline-flex !important;
185
- align-items: center;
186
- justify-content: center;
187
- min-height: 52px;
188
- padding: 12px 22px !important;
189
- margin: 10px 0 !important;
190
- font-size: 15px !important;
191
- font-weight: 600 !important;
192
- color: CanvasText !important;
193
- background: rgba(127, 127, 127, 0.10) !important;
194
- border: 2px solid rgba(127, 127, 127, 0.35) !important;
195
- border-radius: 8px !important;
196
- cursor: pointer !important;
197
- }
198
-
199
-
200
  /* ============================================================
201
  * Persistent "📁 Files" floating toggle (chat.js).
202
  *
@@ -336,28 +248,6 @@ textarea.ac-attach-only {
336
  }
337
 
338
 
339
- /* Hide the cl.Action buttons we use only as click targets for the
340
- * phase pills. Labels like "AC_ADVANCE::kit" / "AC_REVERT::plan"
341
- * are tagged in chat.js as data-ac-phase-nav="advance|revert" and
342
- * hidden here — and so is their host message card via :has(). */
343
- button[data-ac-phase-nav] {
344
- position: absolute !important;
345
- width: 1px !important;
346
- height: 1px !important;
347
- padding: 0 !important;
348
- margin: -1px !important;
349
- overflow: hidden !important;
350
- clip: rect(0, 0, 0, 0) !important;
351
- border: 0 !important;
352
- opacity: 0 !important;
353
- pointer-events: auto !important; /* still clickable from JS */
354
- }
355
- *:has(> button[data-ac-phase-nav]),
356
- [class*="message"]:has(button[data-ac-phase-nav]) {
357
- display: none !important;
358
- }
359
-
360
-
361
  /* ============================================================
362
  * Persistent right workspace panel (sci-space style).
363
  *
@@ -374,7 +264,9 @@ button[data-ac-phase-nav] {
374
  * just offscreen, and might intercept clicks" failure mode. */
375
  #ac-side-panel {
376
  position: fixed !important;
377
- top: 0 !important;
 
 
378
  right: 0 !important;
379
  bottom: 0 !important;
380
  /* 55vw clamped to [480, 900] px so the notebook has real reading
@@ -440,26 +332,30 @@ button[data-ac-phase-nav] {
440
  flex: 1 !important;
441
  width: 44px !important;
442
  height: 100% !important;
 
 
 
 
 
443
  border: none !important;
 
444
  border-radius: 0 !important;
445
- background: linear-gradient(180deg,
446
- color-mix(in oklab, Canvas 88%, currentColor 6%) 0%,
447
- color-mix(in oklab, Canvas 82%, currentColor 10%) 100%) !important;
448
- color: CanvasText !important;
449
  cursor: pointer !important;
450
- font-size: 12.5px !important;
451
  font-weight: 700 !important;
452
  letter-spacing: 0.6px !important;
453
  writing-mode: vertical-rl !important;
454
  text-orientation: mixed !important;
455
  padding: 16px 0 !important;
456
  line-height: 1.3 !important;
 
457
  }
458
  #ac-side-panel[data-state="collapsed"]:hover #ac-side-collapse,
459
  #ac-side-panel.ac-collapsed:hover #ac-side-collapse {
460
- background: linear-gradient(180deg,
461
- color-mix(in oklab, Canvas 78%, currentColor 12%) 0%,
462
- color-mix(in oklab, Canvas 70%, currentColor 16%) 100%) !important;
463
  }
464
 
465
  .ac-side-header {
@@ -669,3 +565,375 @@ body.ac-side-active {
669
  border-left: none;
670
  }
671
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  display: flex;
61
  align-items: center;
62
  justify-content: center;
63
+ gap: 10px;
64
+ padding: 11px 20px;
65
+ /* Theme-adaptive, neutral no purple gradient. Reads as a quiet system
66
+ * notification that matches the rest of the UI in light and dark. */
67
+ background: hsl(var(--popover));
68
+ color: hsl(var(--muted-foreground));
69
+ border-bottom: 1px solid hsl(var(--border));
70
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
71
  Roboto, Helvetica, Arial, sans-serif;
72
+ font-size: 13.5px;
73
  line-height: 1.4;
74
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
 
75
  /* Slide-down on first appearance so it doesn't feel jarring. */
76
  animation: ac-banner-in 0.25s ease-out;
77
  }
 
80
  }
81
  #ac-init-banner b {
82
  font-weight: 600;
83
+ color: hsl(var(--foreground));
84
  }
85
 
86
  .ac-init-spinner {
87
  flex: 0 0 auto;
88
+ width: 15px;
89
+ height: 15px;
90
+ border: 2px solid hsl(var(--border));
91
+ border-top-color: hsl(var(--primary));
92
  border-radius: 50%;
93
  animation: ac-spin 0.9s linear infinite;
94
  }
 
109
  }
110
 
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  /* ============================================================
113
  * Persistent "📁 Files" floating toggle (chat.js).
114
  *
 
248
  }
249
 
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  /* ============================================================
252
  * Persistent right workspace panel (sci-space style).
253
  *
 
264
  * just offscreen, and might intercept clicks" failure mode. */
265
  #ac-side-panel {
266
  position: fixed !important;
267
+ /* Start BELOW the header so the workspace panel/sliver never overlaps the
268
+ * phase bar (it used to cover the right edge and cut off "3. Validation"). */
269
+ top: 56px !important;
270
  right: 0 !important;
271
  bottom: 0 !important;
272
  /* 55vw clamped to [480, 900] px so the notebook has real reading
 
332
  flex: 1 !important;
333
  width: 44px !important;
334
  height: 100% !important;
335
+ display: flex !important;
336
+ align-items: center !important;
337
+ justify-content: center !important;
338
+ /* Theme tokens (not system Canvas) so the strip matches the app in dark
339
+ * mode instead of showing as a jarring light bar; centered label. */
340
  border: none !important;
341
+ border-left: 1px solid hsl(var(--border)) !important;
342
  border-radius: 0 !important;
343
+ background: hsl(var(--popover)) !important;
344
+ color: hsl(var(--muted-foreground)) !important;
 
 
345
  cursor: pointer !important;
346
+ font-size: 12px !important;
347
  font-weight: 700 !important;
348
  letter-spacing: 0.6px !important;
349
  writing-mode: vertical-rl !important;
350
  text-orientation: mixed !important;
351
  padding: 16px 0 !important;
352
  line-height: 1.3 !important;
353
+ transition: background 0.12s ease-out, color 0.12s ease-out !important;
354
  }
355
  #ac-side-panel[data-state="collapsed"]:hover #ac-side-collapse,
356
  #ac-side-panel.ac-collapsed:hover #ac-side-collapse {
357
+ background: hsl(var(--accent)) !important;
358
+ color: hsl(var(--foreground)) !important;
 
359
  }
360
 
361
  .ac-side-header {
 
565
  border-left: none;
566
  }
567
  }
568
+
569
+
570
+ /* ============================================================
571
+ * Top-right Settings menu (injected by chat.js).
572
+ *
573
+ * Consolidates Chainlit's two separate native header controls — the
574
+ * standalone theme toggle (#theme-toggle) and the avatar/user menu
575
+ * (#user-nav-button, which held Settings + Logout) — into a single
576
+ * gear dropdown holding the Light/Dark/System theme switch + Logout.
577
+ * The natives are hidden in favour of this one menu. Colors use
578
+ * Chainlit's own shadcn CSS variables so the menu tracks the active
579
+ * theme automatically in both light and dark.
580
+ * ============================================================ */
581
+
582
+ /* Fold the native controls into our single gear menu. The theme toggle and
583
+ * user/avatar menu are redundant; Readme moves INTO the gear menu but stays
584
+ * in the DOM (display:none) so it remains the click target + layout anchor. */
585
+ #theme-toggle,
586
+ #user-nav-button,
587
+ #readme-button { display: none !important; }
588
+
589
+ /* Gear button — injected into the header flow just LEFT of "Readme"
590
+ * (chat.js). That keeps it clear of both the workspace sliver on the far
591
+ * right and Readme itself, and it inherits the header's vertical alignment.
592
+ * Matches Chainlit's 36px icon-button chrome. */
593
+ #ac-settings-btn {
594
+ /* relative + z-index so the gear always renders ABOVE the fixed workspace
595
+ * sliver (z 10000) even if layout pushes it near the right edge. */
596
+ position: relative;
597
+ z-index: 10001;
598
+ display: inline-flex;
599
+ align-items: center;
600
+ justify-content: center;
601
+ width: 36px;
602
+ height: 36px;
603
+ margin: 0 6px;
604
+ border-radius: 6px;
605
+ border: none;
606
+ background: transparent;
607
+ color: hsl(var(--muted-foreground));
608
+ cursor: pointer;
609
+ transition: background 0.12s ease-out, color 0.12s ease-out;
610
+ }
611
+ #ac-settings-btn:hover,
612
+ #ac-settings-btn[aria-expanded="true"] {
613
+ background: hsl(var(--accent));
614
+ color: hsl(var(--foreground));
615
+ }
616
+ #ac-settings-btn svg { width: 18px; height: 18px; }
617
+
618
+ /* Dropdown card (mounted on <body>, fixed-positioned under the gear). */
619
+ #ac-settings-menu {
620
+ position: fixed;
621
+ z-index: 100000;
622
+ min-width: 248px;
623
+ padding: 8px;
624
+ border-radius: 12px;
625
+ background: hsl(var(--popover));
626
+ color: hsl(var(--popover-foreground));
627
+ border: 1px solid hsl(var(--border));
628
+ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.28);
629
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
630
+ Roboto, Helvetica, Arial, sans-serif;
631
+ animation: ac-menu-in 0.12s ease-out;
632
+ }
633
+ #ac-settings-menu[hidden] { display: none; }
634
+ @keyframes ac-menu-in {
635
+ from { opacity: 0; transform: translateY(-4px); }
636
+ to { opacity: 1; transform: translateY(0); }
637
+ }
638
+
639
+ /* Signed-in-as header. */
640
+ .ac-set-user {
641
+ display: flex;
642
+ align-items: center;
643
+ gap: 10px;
644
+ padding: 6px 8px 10px;
645
+ margin-bottom: 6px;
646
+ border-bottom: 1px solid hsl(var(--border));
647
+ }
648
+ .ac-set-avatar {
649
+ flex: 0 0 auto;
650
+ width: 30px;
651
+ height: 30px;
652
+ border-radius: 50%;
653
+ display: flex;
654
+ align-items: center;
655
+ justify-content: center;
656
+ background: hsl(var(--primary));
657
+ color: #fff;
658
+ font-weight: 700;
659
+ font-size: 13px;
660
+ }
661
+ .ac-set-user-meta { display: flex; flex-direction: column; min-width: 0; }
662
+ .ac-set-user-name {
663
+ font-size: 13px;
664
+ font-weight: 600;
665
+ color: hsl(var(--popover-foreground));
666
+ white-space: nowrap;
667
+ overflow: hidden;
668
+ text-overflow: ellipsis;
669
+ }
670
+ .ac-set-user-sub { font-size: 11px; color: hsl(var(--muted-foreground)); }
671
+
672
+ .ac-set-label {
673
+ font-size: 10.5px;
674
+ font-weight: 700;
675
+ text-transform: uppercase;
676
+ letter-spacing: 0.6px;
677
+ color: hsl(var(--muted-foreground));
678
+ padding: 2px 8px 6px;
679
+ }
680
+
681
+ /* Segmented Light / Dark / System control. */
682
+ .ac-theme-seg {
683
+ display: grid;
684
+ grid-template-columns: repeat(3, 1fr);
685
+ gap: 4px;
686
+ padding: 3px;
687
+ margin: 0 4px 6px;
688
+ background: hsl(var(--accent));
689
+ border-radius: 9px;
690
+ }
691
+ .ac-theme-opt {
692
+ display: flex;
693
+ flex-direction: column;
694
+ align-items: center;
695
+ gap: 4px;
696
+ padding: 8px 4px;
697
+ border: none;
698
+ background: transparent;
699
+ border-radius: 7px;
700
+ cursor: pointer;
701
+ color: hsl(var(--muted-foreground));
702
+ font-size: 11px;
703
+ font-weight: 600;
704
+ transition: background 0.1s ease-out, color 0.1s ease-out;
705
+ }
706
+ .ac-theme-opt svg { width: 16px; height: 16px; }
707
+ .ac-theme-opt:hover { color: hsl(var(--foreground)); }
708
+ .ac-theme-opt.ac-active {
709
+ background: hsl(var(--popover));
710
+ color: hsl(var(--foreground));
711
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20);
712
+ }
713
+
714
+ .ac-set-sep { height: 1px; background: hsl(var(--border)); margin: 6px 4px; }
715
+
716
+ /* Action rows (Logout). */
717
+ .ac-set-row {
718
+ display: flex;
719
+ align-items: center;
720
+ gap: 10px;
721
+ width: calc(100% - 8px);
722
+ margin: 0 4px;
723
+ padding: 9px 10px;
724
+ border: none;
725
+ background: transparent;
726
+ border-radius: 8px;
727
+ cursor: pointer;
728
+ text-align: left;
729
+ color: hsl(var(--popover-foreground));
730
+ font-size: 13px;
731
+ font-weight: 500;
732
+ }
733
+ .ac-set-row svg { flex: 0 0 auto; width: 16px; height: 16px; }
734
+ .ac-set-row:hover { background: hsl(var(--accent)); }
735
+ .ac-set-row.ac-set-danger { color: hsl(var(--destructive)); }
736
+ .ac-set-row.ac-set-danger:hover {
737
+ background: color-mix(in oklab, hsl(var(--destructive)) 14%, transparent);
738
+ }
739
+
740
+
741
+ /* ============================================================
742
+ * Landing chooser cards (chat.js tags the two entry buttons
743
+ * data-ac-entry="create|validate"). The cl.Action label is the
744
+ * card title; the icon (::before) + description (::after) are
745
+ * added here so the first screen reads like a product chooser
746
+ * rather than two emoji chat buttons.
747
+ * ============================================================ */
748
+
749
+ /* Lay the two cards out side by side, centered. Chainlit wraps the
750
+ * AskActionMessage buttons in a flex container — promote whichever
751
+ * element directly holds them to a centered, wrapping row. */
752
+ div:has(> button[data-ac-entry]),
753
+ div:has(> * > button[data-ac-entry]) {
754
+ display: flex !important;
755
+ flex-wrap: wrap;
756
+ gap: 16px;
757
+ justify-content: center;
758
+ align-items: stretch;
759
+ margin: 10px 0 6px;
760
+ }
761
+
762
+ button[data-ac-entry] {
763
+ display: flex !important;
764
+ flex-direction: column !important;
765
+ align-items: flex-start !important;
766
+ justify-content: flex-start !important;
767
+ gap: 10px !important;
768
+ width: 300px !important;
769
+ max-width: 44vw !important;
770
+ min-height: 158px !important;
771
+ padding: 22px 22px 20px !important;
772
+ border-radius: 16px !important;
773
+ border: 1px solid hsl(var(--border)) !important;
774
+ background: hsl(var(--popover)) !important;
775
+ color: hsl(var(--foreground)) !important;
776
+ font-size: 17px !important;
777
+ font-weight: 700 !important;
778
+ line-height: 1.25 !important;
779
+ text-align: left !important;
780
+ white-space: normal !important;
781
+ cursor: pointer !important;
782
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06) !important;
783
+ transition: transform 0.13s ease-out, box-shadow 0.13s ease-out,
784
+ border-color 0.13s ease-out !important;
785
+ }
786
+ button[data-ac-entry]:hover {
787
+ transform: translateY(-2px) !important;
788
+ border-color: hsl(var(--primary)) !important;
789
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.16) !important;
790
+ }
791
+
792
+ /* Icon chip at the top of each card (monochrome, product-style). */
793
+ button[data-ac-entry]::before {
794
+ display: inline-flex;
795
+ align-items: center;
796
+ justify-content: center;
797
+ width: 42px;
798
+ height: 42px;
799
+ border-radius: 11px;
800
+ font-size: 22px;
801
+ font-weight: 700;
802
+ line-height: 1;
803
+ color: hsl(var(--primary));
804
+ background: color-mix(in oklab, hsl(var(--primary)) 14%, transparent);
805
+ }
806
+ button[data-ac-entry="create"]::before { content: "+"; }
807
+ button[data-ac-entry="validate"]::before { content: "\2713"; } /* ✓ */
808
+
809
+ /* Description line under the title. */
810
+ button[data-ac-entry]::after {
811
+ font-size: 13px;
812
+ font-weight: 400;
813
+ line-height: 1.45;
814
+ color: hsl(var(--muted-foreground));
815
+ white-space: normal;
816
+ }
817
+ button[data-ac-entry="create"]::after {
818
+ content: "Design a new competition bundle from a one-line idea or a PDF proposal.";
819
+ }
820
+ button[data-ac-entry="validate"]::after {
821
+ content: "Check an existing bundle against the pre-launch validation criteria.";
822
+ }
823
+
824
+
825
+ /* ============================================================
826
+ * Codabench rebrand — blue accents + light-blue tints to match
827
+ * codabench.org (brand blue #06649e, link #4183c4, tint #f2faff).
828
+ * Overrides Chainlit's shadcn theme variables. login.css loads after
829
+ * Chainlit's bundle, so these win in both light and dark.
830
+ * ============================================================ */
831
+ /* `:root:not(.dark)` (light) and `html.dark` are deliberately MORE specific
832
+ * than Chainlit's own `:root` / `.dark` so these win the cascade tie. */
833
+ :root:not(.dark) {
834
+ --primary: 203 88% 34%; /* Codabench blue */
835
+ --primary-foreground: 0 0% 100%;
836
+ --ring: 203 88% 34%;
837
+ --accent: 205 80% 93%; /* light-blue tint */
838
+ --accent-foreground: 203 80% 26%;
839
+ --secondary: 205 70% 95%;
840
+ --secondary-foreground: 203 70% 26%;
841
+ --background: 208 45% 98.5%; /* faint light-blue page tint */
842
+ }
843
+ html.dark {
844
+ --primary: 203 85% 56%; /* brighter blue on dark */
845
+ --primary-foreground: 0 0% 100%;
846
+ --ring: 203 85% 56%;
847
+ --accent: 203 42% 22%; /* deep blue-tinted accent */
848
+ --accent-foreground: 0 0% 96%;
849
+ --secondary: 203 28% 20%;
850
+ --secondary-foreground: 0 0% 96%;
851
+ }
852
+
853
+
854
+ /* ============================================================
855
+ * Persistent cost / context widget (chat.js) — bottom-left,
856
+ * always-visible session spend + context use with a budget bar.
857
+ * ============================================================ */
858
+ #ac-cost-widget {
859
+ position: fixed;
860
+ left: 14px;
861
+ bottom: 14px;
862
+ z-index: 9000;
863
+ min-width: 152px;
864
+ padding: 8px 11px;
865
+ border-radius: 10px;
866
+ background: hsl(var(--popover));
867
+ border: 1px solid hsl(var(--border));
868
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
869
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
870
+ Roboto, Helvetica, Arial, sans-serif;
871
+ font-size: 12px;
872
+ color: hsl(var(--foreground));
873
+ user-select: none;
874
+ pointer-events: none; /* informational; never blocks clicks */
875
+ }
876
+ .ac-cost-top {
877
+ display: flex;
878
+ align-items: center;
879
+ gap: 8px;
880
+ }
881
+ .ac-cost-budget { font-weight: 600; white-space: nowrap; }
882
+ .ac-cost-bar {
883
+ flex: 1;
884
+ height: 5px;
885
+ min-width: 36px;
886
+ border-radius: 999px;
887
+ background: hsl(var(--accent));
888
+ overflow: hidden;
889
+ }
890
+ .ac-cost-bar > i {
891
+ display: block;
892
+ height: 100%;
893
+ width: 0%;
894
+ background: hsl(var(--primary));
895
+ transition: width 0.4s ease-out, background 0.3s ease-out;
896
+ }
897
+ .ac-cost-ctx {
898
+ margin-top: 4px;
899
+ font-size: 11px;
900
+ color: hsl(var(--muted-foreground));
901
+ }
902
+
903
+
904
+ /* ============================================================
905
+ * Phase-pill custom tooltip + clickable "advance" pill (chat.js).
906
+ * ============================================================ */
907
+ #ac-pill-tip {
908
+ position: fixed;
909
+ z-index: 100002;
910
+ max-width: 280px;
911
+ padding: 8px 11px;
912
+ border-radius: 8px;
913
+ background: hsl(var(--popover));
914
+ color: hsl(var(--popover-foreground));
915
+ border: 1px solid hsl(var(--border));
916
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
917
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
918
+ Roboto, Helvetica, Arial, sans-serif;
919
+ font-size: 12px;
920
+ line-height: 1.45;
921
+ white-space: pre-line; /* render the \n\n in the tip text */
922
+ pointer-events: none;
923
+ }
924
+ #ac-pill-tip[hidden] { display: none; }
925
+
926
+ /* The next-phase pill, once it can be entered: blue, pulsing, clickable. */
927
+ .ac-pp-advance {
928
+ cursor: pointer !important;
929
+ color: #fff !important;
930
+ background: hsl(var(--primary)) !important;
931
+ border-color: hsl(var(--primary)) !important;
932
+ font-weight: 700 !important;
933
+ animation: ac-pp-advance-pulse 1.8s ease-in-out infinite;
934
+ }
935
+ .ac-pp-advance:hover { filter: brightness(1.12); }
936
+ @keyframes ac-pp-advance-pulse {
937
+ 0%, 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.55); }
938
+ 70% { box-shadow: 0 0 0 6px hsl(var(--primary) / 0); }
939
+ }
web/public/logo_dark.png ADDED
web/public/logo_light.png ADDED
web/session_manager.py CHANGED
@@ -277,17 +277,21 @@ class SessionManager:
277
  @staticmethod
278
  async def _ask_entry_mode() -> str:
279
  """Ask the user, upfront, which path they want. Returns 'create'|'validate'."""
 
 
 
 
280
  res = await cl.AskActionMessage(
281
  content=(
282
- "### Welcome to AutoCodabench\n\n"
283
- "What would you like to do? (Start a **New Chat** any time to "
284
- "switch.)"
285
  ),
286
  actions=[
287
  cl.Action(name="entry_mode", payload={"mode": "create"},
288
- label="🛠 Create a bundle from scratch (PDF optional)"),
289
  cl.Action(name="entry_mode", payload={"mode": "validate"},
290
- label=" I have a bundle — validate it"),
291
  ],
292
  timeout=900,
293
  ).send()
@@ -350,23 +354,55 @@ class SessionManager:
350
  content=f"_Model switched to **{label}** (`{model}`)._",
351
  ).send()
352
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  @staticmethod
354
  async def _send_create_greeting(session_id: str) -> None:
355
  """Option A greeting. Must contain the READY_PHRASE for the init-gate."""
 
356
  await cl.Message(
357
  content=(
358
- "# AutoCodabench — create from scratch\n\n"
359
- "Tell me a competition idea — a sentence is enough — and I'll "
360
- "explore the design space with you, citing the literature as "
361
- "we go. You can also drop a PDF / markdown design doc and I'll "
362
- "fill in the gaps.\n\n"
363
- "When the plan is ready I'll show a **▶ Proceed to Phase 2** "
364
- "button. The phase bar at the top tracks your progress. Change "
365
- "the **model** any time from the **⚙️ settings** by the message "
366
- "box.\n\n"
367
- f"_session `{session_id}` · model "
368
- f"`{cl.user_session.get('model') or DEFAULT_MODEL}` · "
369
- f"budget ${MAX_USD_PER_SESSION:.2f}_"
 
 
 
 
 
 
 
 
370
  ),
371
  author="autocodabench",
372
  ).send()
@@ -374,18 +410,29 @@ class SessionManager:
374
  @staticmethod
375
  async def _send_validate_greeting(session_id: str) -> None:
376
  """Option B greeting. Contains the attach-mode READY_PHRASE."""
 
377
  await cl.Message(
378
  content=(
379
- f"# {PHASE_TITLE[PHASE_VALIDATE]} validate an existing bundle\n\n"
380
- "**Attach your bundle `.zip`** below and press send. I'll run "
381
- "the autocodabench check framework against it — including a "
382
- "Docker execution of the baseline (this can take several "
383
- "minutes, and will pull the bundle's `docker_image` if needed) "
384
- " and write you a report.\n\n"
385
- "_Typing is disabled until the bundle is validated; just attach "
386
- "the file and send._\n\n"
387
- f"_session `{session_id}` · LLM-judged checks use model "
388
- f"`{cl.user_session.get('model') or DEFAULT_MODEL}`_"
 
 
 
 
 
 
 
 
 
 
389
  ),
390
  author="autocodabench",
391
  ).send()
 
277
  @staticmethod
278
  async def _ask_entry_mode() -> str:
279
  """Ask the user, upfront, which path they want. Returns 'create'|'validate'."""
280
+ # Labels are intentionally short, clean titles — the per-card
281
+ # description line + icon are added by CSS (login.css, keyed off the
282
+ # data-ac-entry tag chat.js sets), so the landing reads like a product
283
+ # chooser rather than two emoji chat buttons.
284
  res = await cl.AskActionMessage(
285
  content=(
286
+ "## AutoCodabench\n\n"
287
+ "Choose how you'd like to start. You can switch any time "
288
+ "with **New Chat**."
289
  ),
290
  actions=[
291
  cl.Action(name="entry_mode", payload={"mode": "create"},
292
+ label="Create from scratch"),
293
  cl.Action(name="entry_mode", payload={"mode": "validate"},
294
+ label="Validate a bundle"),
295
  ],
296
  timeout=900,
297
  ).send()
 
354
  content=f"_Model switched to **{label}** (`{model}`)._",
355
  ).send()
356
 
357
+ @staticmethod
358
+ def _meta_footer(pairs: list[tuple[str, str]]) -> str:
359
+ """A clean metadata chip row (session / model / budget …).
360
+
361
+ Rendered as inline-styled HTML — config enables unsafe_allow_html, and
362
+ inline styles (unlike CSS classes) survive Chainlit's HTML sanitiser.
363
+ Colours use the app's theme variables so it tracks light/dark.
364
+ """
365
+ chip = (
366
+ '<span style="display:inline-flex;align-items:center;gap:6px;'
367
+ 'padding:3px 10px;border-radius:999px;background:hsl(var(--accent))">'
368
+ '<b style="font-size:10px;font-weight:600;text-transform:uppercase;'
369
+ 'letter-spacing:.4px;opacity:.7">{k}</b>'
370
+ '<code style="font-family:ui-monospace,SFMono-Regular,monospace;'
371
+ 'background:none;color:hsl(var(--foreground))">{v}</code></span>'
372
+ )
373
+ chips = "".join(chip.format(k=k, v=v) for k, v in pairs)
374
+ return (
375
+ '<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:16px;'
376
+ 'padding-top:12px;border-top:1px solid hsl(var(--border));'
377
+ 'font-size:12px">' + chips + "</div>"
378
+ )
379
+
380
  @staticmethod
381
  async def _send_create_greeting(session_id: str) -> None:
382
  """Option A greeting. Must contain the READY_PHRASE for the init-gate."""
383
+ model = cl.user_session.get("model") or DEFAULT_MODEL
384
  await cl.Message(
385
  content=(
386
+ "## Create from scratch\n\n"
387
+ "Tell me a competition idea — a sentence is enough. I'll explore "
388
+ "the design space with you, cite the relevant literature, and "
389
+ "turn it into a plan. You can also drop a PDF or markdown design "
390
+ "doc and I'll fill in the gaps.\n\n"
391
+ "**Not sure where to start? Try one of these:**\n"
392
+ "- *A fair chest-X-ray pneumonia challenge, scored by balanced "
393
+ "accuracy with a penalty for performance gaps across age groups.*\n"
394
+ "- *Forecast next-day household energy use from smart-meter "
395
+ "history, ranked by MASE.*\n"
396
+ "- *Low-resource Swahili→English machine translation, evaluated "
397
+ "with chrF.*\n\n"
398
+ "When the plan is ready, a **▶ Proceed to Phase 2** button "
399
+ "appears. The bar above tracks your progress; switch models "
400
+ "anytime from **⚙ settings** by the message box.\n\n"
401
+ + SessionManager._meta_footer([
402
+ ("session", session_id),
403
+ ("model", model),
404
+ ("budget", f"${MAX_USD_PER_SESSION:.2f}"),
405
+ ])
406
  ),
407
  author="autocodabench",
408
  ).send()
 
410
  @staticmethod
411
  async def _send_validate_greeting(session_id: str) -> None:
412
  """Option B greeting. Contains the attach-mode READY_PHRASE."""
413
+ model = cl.user_session.get("model") or DEFAULT_MODEL
414
  await cl.Message(
415
  content=(
416
+ "## Validate a bundle\n\n"
417
+ "**Attach your bundle `.zip`** below and press send. I'll run the "
418
+ "autocodabench checks against it — including a Docker execution "
419
+ "of the baseline (this can take a few minutes and may pull the "
420
+ "bundle's `docker_image`) then write you a report.\n\n"
421
+ "_Typing stays disabled until validation runs — just attach the "
422
+ "file and send._\n\n"
423
+ "**Don't have a bundle handy?** Download this small example "
424
+ "competition, then attach it above to see validation in action:\n\n"
425
+ '<a href="/public/examples/example-bundle-survival.zip" download '
426
+ 'target="_blank" style="display:inline-flex;align-items:center;'
427
+ 'gap:8px;padding:9px 16px;border-radius:10px;'
428
+ 'background:hsl(var(--primary));color:#fff;font-weight:600;'
429
+ 'font-size:13px;text-decoration:none">&#11015; Download example '
430
+ 'bundle <span style="opacity:.75;font-weight:400">survival '
431
+ '&middot; 80&nbsp;KB</span></a>\n\n'
432
+ + SessionManager._meta_footer([
433
+ ("session", session_id),
434
+ ("judge model", model),
435
+ ])
436
  ),
437
  author="autocodabench",
438
  ).send()
web/streaming.py CHANGED
@@ -236,20 +236,47 @@ class TurnView:
236
  extra = f" · {self._status}" if self._status else ""
237
  return f"_Composing… {_blob(self._frame)} · {elapsed}s{extra}_"
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  def _content(self) -> str:
240
- # Consecutive log lines pack into one tight block (markdown hard breaks);
241
- # narration/footer blocks stand alone so prose and tables render right.
 
242
  blocks: list[str] = []
243
  run: list[str] = []
244
  for it in self._items:
245
  if it["kind"] == "log":
246
  run.append(it["text"])
247
  else:
248
- if run:
249
- blocks.append(" \n".join(run)); run = []
 
250
  blocks.append(it["text"])
251
- if run:
252
- blocks.append(" \n".join(run))
 
253
  tail = self._tail()
254
  if tail:
255
  blocks.append(tail)
@@ -455,25 +482,34 @@ async def run_agent_turn(
455
  cl.user_session.set("cum_cost_usd", cum)
456
 
457
  usage = getattr(message, "usage", None) or {}
458
- if isinstance(usage, dict):
459
- in_tok = int(usage.get("input_tokens") or 0)
460
- out_tok = int(usage.get("output_tokens") or 0)
461
- else:
462
- in_tok = int(getattr(usage, "input_tokens", 0) or 0)
463
- out_tok = int(getattr(usage, "output_tokens", 0) or 0)
 
 
 
 
 
 
 
464
  log.info("[turn] ResultMessage cost=$%.4f cum=$%.4f in_tok=%d out_tok=%d", cost, cum, in_tok, out_tok)
465
  if in_tok:
466
  cl.user_session.set("last_input_tokens", in_tok)
467
  if out_tok:
468
  cl.user_session.set("last_output_tokens", out_tok)
469
 
 
 
 
470
  if cost or in_tok:
471
- ctx_pct = 100.0 * in_tok / CONTEXT_WINDOW_TOKENS if in_tok else 0.0
472
- await view.add_block(
473
- f"_turn ≈ ${cost:.3f} · session "
474
- f"${cum:.2f} / ${MAX_USD_PER_SESSION:.2f} · "
475
- f"ctx {ctx_pct:.1f}% ({in_tok:,} tok)_"
476
- )
477
 
478
  user = cl.user_session.get("user")
479
  CostLog.append(
 
236
  extra = f" · {self._status}" if self._status else ""
237
  return f"_Composing… {_blob(self._frame)} · {elapsed}s{extra}_"
238
 
239
+ def _flush_log_run(self, run: list[str]) -> str:
240
+ """Render a run of tool/step log lines.
241
+
242
+ While the turn is WORKING the steps stream live as plain lines. Once the
243
+ turn is done they collapse into a single hide/unhide ``<details>`` so the
244
+ final answer is what stands out — the user can expand to see every step.
245
+ (Inline styles, not classes, so they survive Chainlit's HTML sanitiser.)
246
+ """
247
+ if not run:
248
+ return ""
249
+ if self._working:
250
+ return " \n".join(run)
251
+ n = len(run)
252
+ inner = "<br>".join(run)
253
+ return (
254
+ '<details style="margin:2px 0"><summary style="cursor:pointer;'
255
+ 'color:hsl(var(--muted-foreground));font-size:13px;'
256
+ 'user-select:none">🔧 '
257
+ f'{n} step{"s" if n != 1 else ""} · show / hide</summary>'
258
+ '<div style="font-family:ui-monospace,SFMono-Regular,monospace;'
259
+ 'font-size:12px;color:hsl(var(--muted-foreground));'
260
+ f'margin:6px 0 4px;line-height:1.7">{inner}</div></details>'
261
+ )
262
+
263
  def _content(self) -> str:
264
+ # Consecutive log lines pack into one tight block (markdown hard breaks
265
+ # while live; a collapsed <details> once done); narration/footer blocks
266
+ # stand alone so prose and tables render right.
267
  blocks: list[str] = []
268
  run: list[str] = []
269
  for it in self._items:
270
  if it["kind"] == "log":
271
  run.append(it["text"])
272
  else:
273
+ block = self._flush_log_run(run); run = []
274
+ if block:
275
+ blocks.append(block)
276
  blocks.append(it["text"])
277
+ block = self._flush_log_run(run)
278
+ if block:
279
+ blocks.append(block)
280
  tail = self._tail()
281
  if tail:
282
  blocks.append(tail)
 
482
  cl.user_session.set("cum_cost_usd", cum)
483
 
484
  usage = getattr(message, "usage", None) or {}
485
+
486
+ def _u(key: str) -> int:
487
+ v = usage.get(key) if isinstance(usage, dict) else getattr(usage, key, 0)
488
+ return int(v or 0)
489
+
490
+ # The context gauge needs the FULL prompt size. With prompt
491
+ # caching, `input_tokens` alone is just the handful of uncached
492
+ # tokens (e.g. 12), so it read ~0% even on a long conversation —
493
+ # add the cached portion (read + creation) back in.
494
+ in_tok = (_u("input_tokens")
495
+ + _u("cache_read_input_tokens")
496
+ + _u("cache_creation_input_tokens"))
497
+ out_tok = _u("output_tokens")
498
  log.info("[turn] ResultMessage cost=$%.4f cum=$%.4f in_tok=%d out_tok=%d", cost, cum, in_tok, out_tok)
499
  if in_tok:
500
  cl.user_session.set("last_input_tokens", in_tok)
501
  if out_tok:
502
  cl.user_session.set("last_output_tokens", out_tok)
503
 
504
+ # Refresh phase_state.json so the always-visible cost/context
505
+ # widget (chat.js, fed by the phase_state poll) shows live
506
+ # numbers. The per-turn line is no longer printed inline.
507
  if cost or in_tok:
508
+ try:
509
+ from phase_manager import PhaseManager
510
+ PhaseManager.write_state(run_dir)
511
+ except Exception:
512
+ log.debug("[turn] phase_state cost refresh failed", exc_info=True)
 
513
 
514
  user = cl.user_session.get("user")
515
  CostLog.append(