refactor(gateway): split monolithic style.css and app.js into per-surface modules#2683
Conversation
…face modules
The gateway's static frontend had grown into two merge-conflict hotspots: a
6 887-line `style.css` and an 11 189-line `app.js`, each a catch-all for every
surface of the SPA. Any two PRs touching different tabs were likely to collide.
This change splits both files by surface/concern while preserving bytes and
behavior. `STYLE_CSS` and `APP_JS` in `crates/ironclaw_gateway/src/assets.rs`
now `concat!(include_str!(...))` the split pieces at compile time, so the
served `/style.css` and `/app.js` URLs are unchanged and the existing
workspace-overlay (`custom.css`) path still works. Cuts land on function /
block boundaries; `node --check` validates the concat. Admin assets move
under `static/admin/` for symmetry. Per-commit safety + CI workflow validate
the split files per-file instead of the old monolith.
- Styles split into 20 files under `static/styles/{base,layout}.css +
styles/{components,primitives,surfaces}/*.css`
- JS split into 24 files under `static/js/{core,surfaces}/*.js`
- No URL, CSP, or behavioural change — pure file-layout refactor
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Refactors the IronClaw web gateway frontend by splitting the previously monolithic static/style.css and static/app.js into smaller per-surface / per-concern modules, while keeping the public /style.css and /app.js URLs stable via compile-time concatenation.
Changes:
- Split gateway JS into
static/js/core/*.jsandstatic/js/surfaces/*.js, concatenated intoAPP_JSat compile time. - Split gateway CSS into
static/styles/{base,layout,components,primitives,surfaces}/*.css, concatenated intoSTYLE_CSSat compile time. - Update docs/tooling/CI (pre-commit safety + workflow checks + internal docs) to point at and validate the new module layout; move admin assets under
static/admin/.
Reviewed changes
Copilot reviewed 52 out of 56 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/channels/web/platform/static_files.rs | Updates inline docs to reference new JS module path for layout config. |
| src/channels/web/CLAUDE.md | Updates docs to reference split onboarding module path. |
| scripts/pre-commit-safety.sh | Updates JS syntax validation from monolithic file to per-module checks. |
| crates/ironclaw_gateway/static/styles/surfaces/users.css | Adds Users-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/tool-permissions.css | Adds Tool Permissions-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/skills.css | Adds Skills-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/settings.css | Adds Settings-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/routines.css | Adds Routines-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/projects.css | Adds Projects-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/missions.css | Adds Missions-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/memory.css | Adds Memory-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/logs.css | Adds Logs-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/config.css | Adds Config-tab CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/auth.css | Adds Auth-screen CSS module. |
| crates/ironclaw_gateway/static/styles/surfaces/activity.css | Adds Activity-tab CSS module. |
| crates/ironclaw_gateway/static/styles/primitives/toast.css | Adds toast primitive CSS module. |
| crates/ironclaw_gateway/static/styles/layout.css | Adds global layout CSS module. |
| crates/ironclaw_gateway/static/styles/components/markdown.css | Adds markdown component CSS module. |
| crates/ironclaw_gateway/static/styles/base.css | Adds base/reset CSS module. |
| crates/ironclaw_gateway/static/js/surfaces/users.js | Adds Users surface JS module. |
| crates/ironclaw_gateway/static/js/surfaces/tool-permissions.js | Adds Tool Permissions surface JS module (plus shared shortcut bindings). |
| crates/ironclaw_gateway/static/js/surfaces/skills.js | Adds Skills surface JS module. |
| crates/ironclaw_gateway/static/js/surfaces/routines.js | Adds Routines surface JS module. |
| crates/ironclaw_gateway/static/js/surfaces/memory.js | Adds Memory surface JS module. |
| crates/ironclaw_gateway/static/js/surfaces/logs.js | Adds Logs surface JS module. |
| crates/ironclaw_gateway/static/js/surfaces/chat.js | Adds Chat surface JS module. |
| crates/ironclaw_gateway/static/js/core/widgets.js | Moves widget + layout logic into a core module. |
| crates/ironclaw_gateway/static/js/core/tool-activity.js | Moves tool activity rendering logic into a core module. |
| crates/ironclaw_gateway/static/js/core/routing.js | Moves hash-routing + navigation helpers into a core module. |
| crates/ironclaw_gateway/static/js/core/render.js | Moves markdown/rendering/sanitization helpers into a core module. |
| crates/ironclaw_gateway/static/js/core/init-auth.js | Moves auth/bootstrap/API helper logic into a core module. |
| crates/ironclaw_gateway/static/js/core/gateway-tee.js | Moves gateway status + TEE UI logic into a core module. |
| crates/ironclaw_gateway/static/js/core/bootstrap.js | Establishes global state + theme bootstrap in a core module. |
| crates/ironclaw_gateway/static/js/core/activity-store.js | Moves active-work state tracking into a core module. |
| crates/ironclaw_gateway/src/assets.rs | Switches APP_JS/STYLE_CSS to compile-time concatenation; updates admin asset paths. |
| CLAUDE.md | Updates repo-level docs to reference new onboarding module path. |
| .github/workflows/code_style.yml | Updates CI JS syntax check to validate all split JS modules. |
| .claude/commands/trace.md | Updates internal tracing docs to reflect split frontend layout. |
| .claude/commands/add-sse-event.md | Updates internal guidance for adding SSE handlers/CSS in the split layout. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (mod && e.key >= '1' && e.key <= '5') { | ||
| e.preventDefault(); | ||
| const tabs = engineV2 | ||
| ? ['chat', 'memory', 'projects', 'settings', 'jobs'] | ||
| : ['chat', 'memory', 'routines', 'settings', 'jobs']; | ||
| const idx = parseInt(e.key) - 1; |
There was a problem hiding this comment.
engineV2 is referenced when building the Mod+1-5 tab list, but the rest of the codebase uses engineV2Enabled. As written this will throw a ReferenceError the first time the shortcut path runs, breaking keyboard navigation. Switch this to the correct global (or derive from activeWorkStore if that’s the intended source).
There was a problem hiding this comment.
Code Review
This pull request refactors the monolithic frontend by splitting app.js and style.css into modular components organized by core functionality and UI surfaces. It updates the asset compilation process, documentation, and pre-commit safety scripts to accommodate the new structure. Feedback identifies several issues, including undefined variable and function references (engineV2, autoGrow), inconsistent property access for engine settings, and a logic flaw in SSE event handling that leads to duplicate messages. Additionally, it was recommended to relocate core widget infrastructure from surface-specific files to a centralized core module.
| const tabs = engineV2 | ||
| ? ['chat', 'memory', 'projects', 'settings', 'jobs'] | ||
| : ['chat', 'memory', 'routines', 'settings', 'jobs']; |
There was a problem hiding this comment.
The variable engineV2 is not defined in this scope. Based on the global definitions in bootstrap.js and usage in other modules (like routing.js), this should be engineV2Enabled.
| const tabs = engineV2 | |
| ? ['chat', 'memory', 'projects', 'settings', 'jobs'] | |
| : ['chat', 'memory', 'routines', 'settings', 'jobs']; | |
| const tabs = engineV2Enabled | |
| ? ['chat', 'memory', 'projects', 'settings', 'jobs'] | |
| : ['chat', 'memory', 'routines', 'settings', 'jobs']; |
| if (input) { | ||
| input.value = 'Create a new project for me. I want to set up an autonomous workspace for: '; | ||
| input.focus(); | ||
| autoGrow(input); |
| const streamingMsg = document.querySelector('.message.assistant[data-streaming="true"]'); | ||
| if (streamingMsg) streamingMsg.removeAttribute('data-streaming'); | ||
|
|
||
| _turnResponseReceived = true; | ||
| if (_doneWithoutResponseTimer) { | ||
| clearTimeout(_doneWithoutResponseTimer); | ||
| _doneWithoutResponseTimer = null; | ||
| } | ||
| finalizeActivityGroup(); | ||
| addMessage('assistant', data.content); |
There was a problem hiding this comment.
This logic will cause duplicate messages in the UI for streaming responses. When a response event arrives, it calls addMessage, which appends a new bubble to the chat. However, if the response was already being streamed via stream_chunk events, a bubble already exists (marked with data-streaming="true").
The code should check for an existing streaming message and update it in place instead of always adding a new one.
// Finalize the response: update the streaming message if present, otherwise add a new one.
const streamingMsg = document.querySelector('.message.assistant[data-streaming="true"]');
if (streamingMsg) {
streamingMsg.removeAttribute('data-streaming');
const contentEl = streamingMsg.querySelector('.message-content');
if (contentEl) {
streamingMsg.setAttribute('data-raw', data.content);
streamingMsg.setAttribute('data-copy-text', data.content);
contentEl.innerHTML = renderMarkdown(data.content);
upgradeStructuredData(contentEl);
}
} else {
addMessage('assistant', data.content);
}
_turnResponseReceived = true;
if (_doneWithoutResponseTimer) {
clearTimeout(_doneWithoutResponseTimer);
_doneWithoutResponseTimer = null;
}
finalizeActivityGroup();| activeWorkStore.setEngineV2Enabled(!!data.engine_v2); | ||
| applyEngineModeUi(); | ||
| refreshPersistentActivityBar(); | ||
|
|
||
| // Update restart button visibility | ||
| restartEnabled = data.restart_enabled || false; | ||
| updateRestartButtonVisibility(); | ||
|
|
||
| // Apply engine v2 / v1 tab visibility once. | ||
| if (!engineModeApplied) { |
There was a problem hiding this comment.
Inconsistent property access on the data object. Line 33 uses data.engine_v2 while line 42 uses data.engine_v2_enabled. This will likely result in engineV2Enabled being incorrectly set to false in one of the steps if the backend only provides one of these keys.
| activeWorkStore.setEngineV2Enabled(!!data.engine_v2); | |
| applyEngineModeUi(); | |
| refreshPersistentActivityBar(); | |
| // Update restart button visibility | |
| restartEnabled = data.restart_enabled || false; | |
| updateRestartButtonVisibility(); | |
| // Apply engine v2 / v1 tab visibility once. | |
| if (!engineModeApplied) { | |
| activeWorkStore.setEngineV2Enabled(!!data.engine_v2_enabled); | |
| applyEngineModeUi(); | |
| refreshPersistentActivityBar(); | |
| // Update restart button visibility | |
| restartEnabled = data.restart_enabled || false; | |
| updateRestartButtonVisibility(); | |
| // Apply engine v2 / v1 tab visibility once. | |
| if (!engineModeApplied) { | |
| engineV2Enabled = !!data.engine_v2_enabled; |
| e.target.dataset.edited = e.target.value ? '1' : ''; | ||
| }); | ||
|
|
||
| // ==================== Widget Extension System ==================== |
There was a problem hiding this comment.
The Widget Extension System logic (defining window.IronClaw, registerWidget, and IronClaw.api) is core infrastructure and should ideally reside in a core module (e.g., static/js/core/widgets.js) rather than a surface-specific module like config.js. This would improve maintainability and ensure core APIs are initialized predictably.
Synthesizes recurring patterns from ~30 merged PRs, 147 bot review comments (Copilot/Gemini), human reviews, and ~50 issues filed in the past 2 weeks. Each rule cites the motivating PR/issue numbers. New files: - error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?, poisoned caches), persist-then-reload atomicity, channel-edge error mapping. (#2526, #2633, #2653, #2673, #2546, #2407, #2408) - agent-evidence.md — side-effect claims must cite tool evidence, empty-fast outputs are errors, external-effect tools must read back, setup UI round-trip. (#2544, #2580, #2582, #2541, #2545, #2411, #2543, #2586) - lifecycle.md — discovery vs. activation, terminal auth rejection, list_installed vs. list_active, deactivation unwinds, snapshot rehydrate must re-validate. (#2556, #2557, #2558, #2564, #2419, PR #2617, PR #2631) Extended: - types.md — from_trusted boundary rule, validated-newtype template with shared validate(&str), serde(try_from) required for validated types, wire-stable enums (no Debug; serde alias for migrations), canonical wire-contract field naming. (PR #2685, #2681, #2687, #2678, #2669, #2665, #2683, #2702) - safety-and-sandbox.md — every new ingress scans pre-transform/pre- injection, bounded resources (interners/streams/fan-out caps), cache keys must be complete. (#2491, #2676, #2470, #2633, #2673, #2710, PR #2702) - review-discipline.md — PR scope discipline, guardrail scripts are code (regression tests, grouped-import parsing, CI has_code inclusion), absolute-path ban in committed docs, stale comments after refactors. (PR #2668, #2628, #2680, #2687, #2647, #2689, #2701) All new files carry paths: frontmatter so they auto-load only on matching files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(rules): add review-driven guidance for Claude Code Synthesizes recurring patterns from ~30 merged PRs, 147 bot review comments (Copilot/Gemini), human reviews, and ~50 issues filed in the past 2 weeks. Each rule cites the motivating PR/issue numbers. New files: - error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?, poisoned caches), persist-then-reload atomicity, channel-edge error mapping. (#2526, #2633, #2653, #2673, #2546, #2407, #2408) - agent-evidence.md — side-effect claims must cite tool evidence, empty-fast outputs are errors, external-effect tools must read back, setup UI round-trip. (#2544, #2580, #2582, #2541, #2545, #2411, #2543, #2586) - lifecycle.md — discovery vs. activation, terminal auth rejection, list_installed vs. list_active, deactivation unwinds, snapshot rehydrate must re-validate. (#2556, #2557, #2558, #2564, #2419, PR #2617, PR #2631) Extended: - types.md — from_trusted boundary rule, validated-newtype template with shared validate(&str), serde(try_from) required for validated types, wire-stable enums (no Debug; serde alias for migrations), canonical wire-contract field naming. (PR #2685, #2681, #2687, #2678, #2669, #2665, #2683, #2702) - safety-and-sandbox.md — every new ingress scans pre-transform/pre- injection, bounded resources (interners/streams/fan-out caps), cache keys must be complete. (#2491, #2676, #2470, #2633, #2673, #2710, PR #2702) - review-discipline.md — PR scope discipline, guardrail scripts are code (regression tests, grouped-import parsing, CI has_code inclusion), absolute-path ban in committed docs, stale comments after refactors. (PR #2668, #2628, #2680, #2687, #2647, #2689, #2701) All new files carry paths: frontmatter so they auto-load only on matching files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(rules): split agent-evidence into prompt + code rule agent-evidence.md mixed two concerns: runtime agent instruction (what the LLM should do when concluding a turn) and code-enforcement rules (what the dispatcher, engine, and tools must implement). Rules under .claude/rules/ only guide Claude Code when editing the repo — the runtime agent never reads them. Splits the two: - crates/ironclaw_engine/prompts/codeact_postamble.md — new section "Evidence before claiming side effects". Sits next to the existing "FINAL() answer quality" guidance; loaded via include_str! in executor/prompt.rs (no Rust change needed). - .claude/rules/tool-evidence.md — renamed from agent-evidence.md, keeps only the code invariants (engine v2 side-effect gate, empty-fast ToolError::EmptyResult, external-effect tools must read back, setup UI round-trip). Prompt tests pass unchanged; the postamble addition is pure text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * prompt: tighten evidence rule to FINAL() claims only, not tool use Live-test validation of the "Evidence before claiming side effects" section (added in the prior commit) showed it inhibited legitimate tool use. With the original wording, `zizmor_scan_v2` live-recording timed out at 302s with zero responses; reverting the postamble restored healthy behavior (88s run, 8 shell calls including `cargo install zizmor` and full workflow analysis). The original phrasing conflated two things: what the agent should claim and what tools it should call. The rule is only about the claim. Re-tunes the section to: - Open with an explicit "this does not restrict tool calls" scope. - Drop the "<1ms = failure" heuristic (too broad — normal tools like `tool_info(schema)` are legitimately fast). - Drop the full enumeration of forbidden side-effect verbs; keep the rule narrower and clearer. - Shorten the code example (remove redundant early-return). Re-tuned run: agent is active (shell calls, real reasoning), live recording completes in ~9s. The remaining test failure is a pre-existing assertion bug (exact `t == "shell"` match against tool strings that now carry arguments like `"shell(cmd)"`) — reproduces with the old postamble too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(live): fix tool-name assertions + re-record zizmor traces The two `zizmor_scan*` live tests had four broken tool-name assertions that silently failed to match: `tools.iter().any(|t| t == "shell")` against a tool list that now contains `"shell(cmd)"` strings (tool events carry args via `format_action_display_name` in `src/bridge/router.rs`). Two of the four were negative assertions checking for the absence of `tool_install` recovery loops — those silently passed even when a recovery loop actually ran. `sandbox_live_e2e.rs:203` already used the correct `t == "shell" || t.starts_with("shell(")` pattern; applied it consistently to all four sites. Verified live: - `IRONCLAW_LIVE_TEST=1 cargo test --test e2e_live -- zizmor_scan --ignored --test-threads=1` → 2 passed, 0 failed, 51.78s. Agent installs and runs zizmor end-to-end, producing real findings (exit code 14, dangerous triggers, excessive permissions, etc.). Traces re-recorded with the tuned postamble (commit 50d8517) and scrubbed: replaced `/home/illia/.cargo/bin/zizmor` with `/home/user/.cargo/bin/zizmor` per the developer-local-path ban in `.claude/rules/review-discipline.md`. No credentials, PII, or high-entropy secrets in either trace (only git SHAs from zizmor's workflow analysis output). Replay still passes: `cargo test --test e2e_live -- zizmor_scan --ignored` → 2/2 ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(replay): update zizmor_scan_v2 insta snapshot The engine v2 replay-snapshot gate (`engine_v2_tests::snapshot_zizmor_scan_v2`) failed against the re-recorded trace from 1691efe because the old snapshot encoded a broken run: - final_state: Failed - Missing Assistant message role - 3 issues: thread_failure (error), no_response (warning), llm_error (error) - 6 tool calls that never produced a final answer The new trace completes cleanly: - final_state: Done - System / User / Assistant roles present - 1 issue: mixed_mode (info) - 3 shell tool calls + successful `FINAL()` with real findings The snapshot was pinning a regression. Regenerated with `INSTA_UPDATE=always cargo test --test e2e_engine_v2 -- snapshot_zizmor_scan_v2`; passes on replay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(rules): address PR #2714 review feedback - review-discipline: reword "Doc Absolute Paths" as a review convention (pre-commit only scans .rs; the rule misleadingly claimed enforcement). - safety-and-sandbox: broaden `paths:` frontmatter to include the actual ingress owners (`bridge`, `channels`, `workspace`, `agent`, engine crate) so the rule auto-loads where it applies. - tool-evidence: mark the side-effect gate, empty-fast rule, and `unverified` flag as target/aspirational invariants — neither `ToolError::EmptyResult`, an `unverified` field on `ToolOutput`, nor a byte-count field on `ActionRecord` exist today. Point at concrete interim conventions (`ToolError::ExecutionFailed`, `unverified: true` in the JSON result body). - types: scope "Validated newtypes must gate Deserialize" to *new* types, document the `CredentialName`/`ExtensionName` exception (they intentionally use `#[serde(transparent)]` + derived `Deserialize` under the `serde_does_not_revalidate` test). Clarify the `from_trusted` trust boundary (trusted = typed upstream, untrusted = raw JSON field even if the field *name* is "registry entry"). Switch `new` template to `impl Into<String>` to avoid an unnecessary clone when an owned `String` is passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(rules): simplify types.md + split doc-hygiene; address review round 2 - types: collapse two templates into one canonical validated-newtype shape. New types use `#[serde(try_from = "String")]` with a shared `validate(&str)` helper — no more dual "transparent for some / try_from for others" guidance. `CredentialName`/`ExtensionName` are documented as the sole legacy exception (locked in by the `serde_does_not_revalidate` test); new code must not copy their `transparent` + `from_trusted` pattern. Removes the long "Using `from_trusted` safely" section and the separate "Validated newtypes must gate Deserialize" subsection that contradicted the Don'ts list. - doc-hygiene: new tiny rule file scoped to `**/*.md`, `**/*.py`, `docs/**` that carries the "no developer-local absolute paths in committed docs" convention. Removed from review-discipline.md where its `src/**/*.rs` scope meant the rule never loaded on the files it governed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(live): match hyphenated tool-install in attempted_relevant_tool The engine records `action_name` as the raw string the LLM emitted (`crates/ironclaw_engine/src/executor/structured.rs:381`), and the registry's lookup canonicalization only affects dispatch — not the name that reaches `StatusUpdate::ToolStarted`. The two other predicates in this file (`bad_recovery` at :420, `phase_b_recovery` at :531) already defend against both forms; this one should too, for consistency. Addresses PR #2714 review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
static/style.css(6 887 lines) andstatic/app.js(11 189 lines) — into per-surface / per-concern modules so PRs touching different tabs stop colliding on the same file.STYLE_CSSandAPP_JSincrates/ironclaw_gateway/src/assets.rsnowconcat!(include_str!(...))the split pieces at compile time./style.cssand/app.jsURLs are unchanged, the workspacecustom.cssoverlay still works, and bytes served are the same (plus a\nseparator between files).static/admin/for symmetry; docs + pre-commit-safety + CI workflow updated to point at the new layout and validate split files per-file.Layout
static/styles/{base,layout}.css + styles/{components,primitives,surfaces}/*.cssstatic/js/{core,surfaces}/*.jsstatic/admin/{admin.css, admin.js}Cuts land on function / top-level-symbol boundaries. A
diff <(sort original) <(cat splits | sort)returned zero;node --checkvalidates the concat.Non-goals
No URL, CSP, behaviour, or logic change. This is a pure file-layout refactor — the follow-up design-system work (token restructure, primitives, Defuse-inspired visual language) lands on top of this clean split in separate PRs.
Test plan
cargo check -p ironclaw_gateway— cleancargo clippy --all --benches --tests --examples --all-features— zero warningscargo fmt --check— cleancargo test --lib 'channels::web'— 429 pass (incl.test_css_handler_returns_base_in_multi_tenant_mode)cargo test -p ironclaw_gateway --lib— 60 passbash scripts/pre-commit-safety.sh— passnode --checkon full concat'd JS — passcargo rungateway, open browser, click through every tab (reviewer)🤖 Generated with Claude Code