Merge branch 'master' into pr180

This commit is contained in:
Codeman maintainer
2026-08-04 23:37:59 +02:00
77 changed files with 7995 additions and 541 deletions
+37 -1
View File
@@ -159,12 +159,48 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
// the exemption should stay no wider than the problem it solves.
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
if (url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
if (url.startsWith('/ws/') || url.startsWith('/q/')) return false;
// Anything that resolves to a REAL Codeman route is refused, which is the fence
// that keeps this from being an auth bypass. `/api/` used to be refused by prefix
// instead, but dashboards legitimately serve assets from their own `/api/...`
// namespace (`<img src="/api/hero?slug=x">`), and those requests were the one
// class the 404 relay could never rescue. See matchesRegisteredRoute.
if (matchesRegisteredRoute(req, url)) return false;
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}
/**
* Whether `url` resolves to a route Codeman actually registered.
*
* `hasRoute()` is the wrong tool: it matches the registered PATTERN literally, so
* `/api/sessions/abc` reports false against a registered `/api/sessions/:id` and
* would hand out an exemption on a live API route. `findRoute()` performs the real
* radix-tree lookup and fills in `params`, which is what this needs.
*
* The one complication is `@fastify/static`, mounted at `/`, which registers a
* root-level catch-all that matches EVERY path. A match on that means "no real
* route, this is heading for the 404 handler", and it is distinguishable because a
* root catch-all is the only route whose `*` param comes back equal to the entire
* request path. `test/webview-auth-exemption.test.ts` pins both halves of that.
*
* Fails CLOSED: anything unexpected counts as a real route, which merely denies the
* exemption and restores the previous behavior.
*/
function matchesRegisteredRoute(req: FastifyRequest, url: string): boolean {
try {
const found = req.server.findRoute({ method: req.method as 'GET' | 'HEAD', url });
if (!found) return false;
const params = found.params ?? {};
const keys = Object.keys(params);
const isRootCatchAll = keys.length === 1 && keys[0] === '*' && `/${params['*']}` === url;
return !isRootCatchAll;
} catch {
return true;
}
}
/**
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set.
+36 -3
View File
@@ -800,6 +800,10 @@ class CodemanApp {
this.applyLocalization();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
// Must run before the first session:created can arrive: markSessionTabEntering()
// ignores ids until this sets up its state, which is what keeps the tabs
// restored on page load from animating.
this.initEntranceAnimations?.();
// Remove mobile-init class now that JS has applied visibility settings.
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
document.documentElement.classList.remove('mobile-init');
@@ -1563,6 +1567,12 @@ class CodemanApp {
this.sessionOrder.push(data.id);
this.saveSessionOrder();
}
// Idempotent per id: the POST response and the session:created event both
// land here, and a batch launched together cascades in creation order.
this.markSessionTabEntering?.(data.id);
// The pane is one shared element, so it is only marked here and played when
// this session is actually selected (see selectSession).
this.markTerminalEntering?.(data.id);
this.renderSessionTabs();
this.updateCost();
// Start stats polling when first session appears
@@ -1974,7 +1984,7 @@ class CodemanApp {
// Render conversation thread
const mode = this.sessions.get(this.activeSessionId)?.mode;
const agentLabel =
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'opencode' ? 'OpenCode' : 'Claude';
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'antigravity' ? 'Antigravity' : mode === 'opencode' ? 'OpenCode' : 'Claude';
body.innerHTML = '';
for (const msg of messages) {
const div = document.createElement('div');
@@ -3280,6 +3290,13 @@ class CodemanApp {
}
_renderSessionTabsImmediate() {
// Same guard as renderSessionTabs()/_fullRenderSessionTabs(): the incremental
// branch below rewrites .tab-name's innerHTML, which destroys the inline rename
// <input> mid-keystroke. Guarding only the scheduler is not enough: a render
// debounced just BEFORE the rename opened still fires ~100ms later and lands
// here directly. finishRename() re-renders on both commit and cancel, so a
// render dropped here is picked back up when the rename settles.
if (this._inlineRenameActive) return;
const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
@@ -3443,6 +3460,13 @@ class CodemanApp {
}
this.updateTabOverflowMode();
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
// so measuring mid-animation would decide the wrap on collapsed widths.
this._applyTabEntrances?.();
// Phone overview rides on this one call: every state change it cares about
// (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks)
// already funnels through here. No-ops unless that surface is showing.
this._refreshMobileOverviewIfVisible?.();
}
// Auto-wrap desktop session tabs to a second row when they overflow one row,
@@ -3532,7 +3556,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : ''}
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : ''}
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
@@ -3569,6 +3593,9 @@ class CodemanApp {
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
// tabs-auto-wrap class until the next content render.
this.updateTabOverflowMode();
// Newly created tabs animate in; a re-render mid-cascade resumes them rather
// than restarting, since this rebuild just destroyed the animating elements.
this._applyTabEntrances?.();
}
// Set up arrow key navigation for session tabs (accessibility)
@@ -4100,6 +4127,10 @@ class CodemanApp {
// selectSession or reconnect catches up.
this._updateSseSubscription(sessionId);
this.hideWelcome();
// Terminal-pane entrance: plays for a freshly created session, and on every
// switch when that option is on. Transform/opacity/clip-path only, xterm's
// FitAddon reads the untransformed layout box, so this cannot reach the PTY.
this.playTerminalEntrance?.(sessionId);
// Clear idle hooks on view, but keep action hooks until user interacts
this.clearPendingHooks(sessionId, 'idle_prompt');
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
@@ -4610,7 +4641,9 @@ class CodemanApp {
? 'Kill Tmux & Codex'
: session.mode === 'gemini'
? 'Kill Tmux & Gemini'
: 'Kill Tmux & Claude Code';
: session.mode === 'antigravity'
? 'Kill Tmux & Antigravity'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+754
View File
@@ -0,0 +1,754 @@
/**
* @fileoverview Entrance animations for the four things that appear when work
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
* One picker per surface, plus themes that set all four to a matching look.
*
* Everything is OFF by default (the `legacy` theme), so an untouched install
* behaves exactly as it did before this module existed. Opt in via App Settings
* → Appearance → Entrance Animations.
*
* Four constraints shape the design:
*
* 1. `_fullRenderSessionTabs()` replaces the tab strip's entire innerHTML, and
* `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''` and rebuilds
* every path. Both run constantly while sessions and agents are spawning, so
* an animating tab or line element is DESTROYED mid-flight. Those two are
* therefore tracked by id in `_tabEnterActive` / `_lineEnterActive` and
* re-applied to the fresh element with a NEGATIVE animation-delay, resuming at
* the same offset instead of restarting or snapping to the end. Windows are
* stable DOM and need none of this.
* 2. Connection-line geometry comes from `getBoundingClientRect()` on the window.
* A window entrance that starts with a transform would move that rect, so the
* `beam` style (which must hold still while its line draws toward it) animates
* opacity and filter only. Every other style refreshes the lines when it ends.
* 3. The terminal pane is ONE shared element, so its entrance is marked at
* session creation but played at selection: a session created in the
* background must not animate the pane the user is currently looking at. Its
* styles are also restricted to transform/opacity/clip-path (see below).
* 4. Nothing may animate on page load or reconnect replay. Only ids that pass
* through `markSessionTabEntering()` animate, and `_tabEnterSeen` makes that
* once-per-id even though the POST response and the SSE event both call
* `_onSessionCreated`.
*
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
* so styles can be compared without spawning real sessions or agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
*/
/** Tab entrance styles. `key` doubles as the `data-tab-anim` value. */
const TAB_ANIM_STYLES = [
{ key: 'slide', label: 'Slide', blurb: 'Drifts in from the right.', duration: 380 },
{ key: 'pop', label: 'Pop', blurb: 'Springs past full size, then settles.', duration: 460 },
{ key: 'crt', label: 'CRT', blurb: 'Snaps open as a hot line, then unfolds.', duration: 520 },
{ key: 'unroll', label: 'Unroll', blurb: 'The strip makes room and the tab widens in.', duration: 480 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 720 },
{ key: 'flip', label: 'Flip', blurb: 'Drops in as a card hinged on its top edge.', duration: 520 },
{ key: 'off', label: 'Off', blurb: 'Tabs just appear.', duration: 0 },
];
/**
* Window entrance styles. `fly` is the pre-existing behaviour (the window flies
* out of its parent tab via a JS transition in subagent-windows.js); every other
* style positions the window at its resting spot and runs a CSS animation there.
*/
const WIN_ANIM_STYLES = [
{ key: 'fly', label: 'Fly from tab', blurb: 'Current behaviour: flies out of the tab, scaling up.', duration: 400 },
{ key: 'crt', label: 'CRT', blurb: 'Bursts open as a hot line, then unfolds vertically.', duration: 560 },
{ key: 'materialize', label: 'Materialize', blurb: 'Resolves out of a blur with a short glitch.', duration: 620 },
{ key: 'unfold', label: 'Unfold', blurb: 'Hinges down from its top edge in 3D.', duration: 560 },
{ key: 'beam', label: 'Beam down', blurb: 'Waits for its line to reach it, then materializes.', duration: 620 },
{ key: 'pop', label: 'Pop', blurb: 'Springs open from its centre.', duration: 460 },
{ key: 'off', label: 'Off', blurb: 'Windows just appear.', duration: 0 },
];
/** Connection-line entrance styles. `key` doubles as the `data-line-anim` value. */
const LINE_ANIM_STYLES = [
{ key: 'draw', label: 'Draw', blurb: 'Draws itself from the tab down to the window.', duration: 420 },
{ key: 'packet', label: 'Packet', blurb: 'Line fades in, then a bright packet runs down it.', duration: 700 },
{ key: 'fade', label: 'Fade', blurb: 'Simply fades in.', duration: 300 },
{ key: 'off', label: 'Off', blurb: 'Lines just appear.', duration: 0 },
];
/**
* Main-terminal entrance styles: the pane a session's CLI actually runs in.
*
* ⚠ These may only animate transform, opacity and clip-path. xterm's FitAddon
* derives rows/cols from `getComputedStyle(parent).width/height`, which reports
* the untransformed layout box, so transforms are safe, but animating width,
* height or padding would feed wrong dimensions into `resize()` and through to
* the PTY. Colour washes go on `.terminal-container::before`, never a `filter`
* on the container: that would blur a full-screen WebGL canvas every frame.
*/
const TERM_ANIM_STYLES = [
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
/** How long a `beam` window waits before materializing. Just under the line draw. */
const BEAM_HOLD_MS = 360;
/** One-click combinations that read as a single look. */
const ANIM_THEMES = [
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
];
/**
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
* `fly` behaviour Codeman already had before this module existed. So a user who
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
* Entrance Animations, which persists to the localStorage keys below.
*/
const TAB_ANIM_DEFAULT = 'off';
const WIN_ANIM_DEFAULT = 'fly';
const LINE_ANIM_DEFAULT = 'off';
const TERM_ANIM_DEFAULT = 'off';
const TAB_ANIM_STAGGER_DEFAULT = 90;
/** A new id joins the current cascade if it arrives within this of the last one. */
const TAB_ANIM_BATCH_WINDOW_MS = 600;
const ANIM_KEYS = {
tab: 'codeman:tabAnim',
win: 'codeman:winAnim',
line: 'codeman:lineAnim',
term: 'codeman:termAnim',
termSwitch: 'codeman:termAnimOnSwitch',
stagger: 'codeman:tabAnimStagger',
speed: 'codeman:tabAnimSpeed',
};
Object.assign(CodemanApp.prototype, {
// ── Setup ─────────────────────────────────────────────────────────────────
/** Resolve every style/timing and stamp them on <html>. Called once at startup. */
initEntranceAnimations() {
this._tabEnterSeen = new Set();
this._tabEnterActive = new Map();
this._lineEnterActive = new Map();
this._tabEnterBatchIndex = 0;
this._tabEnterLastMarkTs = 0;
const params = new URLSearchParams(location.search);
// ?tabanim= / ?winanim= / ?lineanim= win for a single load, so a style can be
// tried without persisting over whatever is saved.
const pick = (param, styles, storeKey, fallback) => {
const fromUrl = params.get(param);
if (styles.some((s) => s.key === fromUrl)) return fromUrl;
return this._animRead(storeKey, fallback);
};
this.setTabAnimStyle(pick('tabanim', TAB_ANIM_STYLES, ANIM_KEYS.tab, TAB_ANIM_DEFAULT), { persist: false });
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
this.setAnimSpeed(Number(this._animRead(ANIM_KEYS.speed, 1)), { persist: false });
if (params.get('animlab') === '1' || params.get('tabanimlab') === '1') this.openAnimLab();
},
_animRead(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null || raw === '' ? fallback : raw;
} catch {
return fallback;
}
},
_animWrite(key, value) {
try {
localStorage.setItem(key, String(value));
} catch {
/* private mode / quota, the in-memory value still applies for this load */
}
},
_setAnimStyle(prop, key, styles, fallback, attr, storeKey, persist) {
const style = styles.some((s) => s.key === key) ? key : fallback;
this[prop] = style;
document.documentElement.setAttribute(attr, style);
if (persist) this._animWrite(storeKey, style);
// Keep the lab's highlight honest when a style is set from anywhere other
// than the lab's own buttons (a theme, a URL param, the console).
this._syncAnimLab?.();
},
setTabAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_tabAnimStyle', key, TAB_ANIM_STYLES, TAB_ANIM_DEFAULT, 'data-tab-anim', ANIM_KEYS.tab, persist);
},
setWinAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_winAnimStyle', key, WIN_ANIM_STYLES, WIN_ANIM_DEFAULT, 'data-win-anim', ANIM_KEYS.win, persist);
},
setLineAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_lineAnimStyle', key, LINE_ANIM_STYLES, LINE_ANIM_DEFAULT, 'data-line-anim', ANIM_KEYS.line, persist);
},
setTermAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
},
/** Replay the terminal entrance on every tab switch, not just on a new session. */
setTermAnimOnSwitch(on, { persist = true } = {}) {
this._termAnimOnSwitch = !!on;
if (persist) this._animWrite(ANIM_KEYS.termSwitch, on ? '1' : '0');
this._syncAnimLab?.();
},
/** Apply a theme: one look across tabs, windows, lines and the terminal. */
setAnimTheme(themeKey) {
const theme = ANIM_THEMES.find((t) => t.key === themeKey);
if (!theme) return;
this.setTabAnimStyle(theme.tab);
this.setWinAnimStyle(theme.win);
this.setLineAnimStyle(theme.line);
this.setTermAnimStyle(theme.term);
this._syncEntranceAnimSetting?.();
},
/** The theme matching the four current styles, or 'custom' for a lab mix. */
currentAnimTheme() {
const match = ANIM_THEMES.find(
(t) =>
t.tab === this._tabAnimStyle &&
t.win === this._winAnimStyle &&
t.line === this._lineAnimStyle &&
t.term === this._termAnimStyle
);
return match ? match.key : 'custom';
},
// ── App Settings picker ───────────────────────────────────────────────────
//
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
// styles live in their own localStorage keys, so they stay per-device and never
// reach `PUT /api/settings`, whose schema is .strict() and would reject them.
_syncEntranceAnimSetting() {
const sel = document.getElementById('appSettingsEntranceAnim');
if (!sel) return;
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
},
setTabAnimStagger(ms, { persist = true } = {}) {
const value = Number.isFinite(ms) ? Math.max(0, Math.min(400, Math.round(ms))) : TAB_ANIM_STAGGER_DEFAULT;
this._tabAnimStagger = value;
if (persist) this._animWrite(ANIM_KEYS.stagger, value);
},
setAnimSpeed(multiplier, { persist = true } = {}) {
const value = Number.isFinite(multiplier) ? Math.max(0.25, Math.min(3, multiplier)) : 1;
this._animSpeed = value;
// Every keyframe block reads this, so one variable retimes all of them.
document.documentElement.style.setProperty('--anim-enter-scale', String(1 / value));
if (persist) this._animWrite(ANIM_KEYS.speed, value);
},
_styleDuration(styles, key) {
return (styles.find((s) => s.key === key)?.duration || 0) / (this._animSpeed || 1);
},
_tabAnimDuration() {
return this._styleDuration(TAB_ANIM_STYLES, this._tabAnimStyle);
},
_winAnimDuration() {
return this._styleDuration(WIN_ANIM_STYLES, this._winAnimStyle);
},
_lineAnimDuration() {
return this._styleDuration(LINE_ANIM_STYLES, this._lineAnimStyle);
},
_termAnimDuration() {
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
},
// ── Tabs ──────────────────────────────────────────────────────────────────
/** Queue a session id to animate on its next render. Idempotent per id. */
markSessionTabEntering(id) {
if (!id || this._tabAnimStyle === 'off') return;
if (!this._tabEnterSeen) return; // initEntranceAnimations() has not run yet
if (this._tabEnterSeen.has(id)) return;
this._tabEnterSeen.add(id);
const now = performance.now();
// A launch landing well after the previous one starts its own cascade rather
// than inheriting a large stale offset.
if (now - this._tabEnterLastMarkTs > TAB_ANIM_BATCH_WINDOW_MS) this._tabEnterBatchIndex = 0;
this._tabEnterLastMarkTs = now;
this._tabEnterActive.set(id, {
startTs: now,
staggerMs: this._tabEnterBatchIndex * this._tabAnimStagger,
});
this._tabEnterBatchIndex += 1;
},
/** Attach (or resume) the entrance animation on freshly rendered tabs. */
_applyTabEntrances() {
const active = this._tabEnterActive;
if (!active || active.size === 0) return;
if (this._tabAnimStyle === 'off') {
active.clear();
return;
}
const container = this.$('sessionTabs');
if (!container) return;
const now = performance.now();
const duration = this._tabAnimDuration();
for (const [id, state] of active) {
const elapsed = now - state.startTs;
// Ran to completion while the element was detached, nothing left to show.
if (elapsed > state.staggerMs + duration + 50) {
active.delete(id);
continue;
}
const tab = container.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (!tab) continue; // not rendered yet; a later render picks it up
// Already running on this element. Re-stamping the delay would jump it, and
// the incremental render path can reach here for the same element.
if (tab.classList.contains('tab-enter')) continue;
// Negative delay resumes mid-animation, so an unrelated re-render mid-cascade
// does not restart the tab or make it snap.
tab.style.setProperty('--tab-enter-delay', `${state.staggerMs - elapsed}ms`);
tab.classList.add('tab-enter');
const done = () => {
tab.classList.remove('tab-enter');
tab.style.removeProperty('--tab-enter-delay');
this._tabEnterActive?.delete(id);
};
tab.addEventListener('animationend', done, { once: true });
tab.addEventListener('animationcancel', done, { once: true });
}
},
// ── Main terminal ─────────────────────────────────────────────────────────
/**
* Queue the terminal pane to animate the first time this session is shown.
* Marked at creation but PLAYED at selection, because the pane is one shared
* element: a session created in the background must not animate the pane the
* user is currently looking at.
*/
markTerminalEntering(id) {
if (!id || this._termAnimStyle === 'off') return;
if (!this._termEnterPending) this._termEnterPending = new Set();
this._termEnterPending.add(id);
},
/** Play the terminal entrance for `sessionId`, if it is owed one. */
playTerminalEntrance(sessionId) {
const style = this._termAnimStyle || TERM_ANIM_DEFAULT;
if (style === 'off') return;
const owed = sessionId && this._termEnterPending?.delete(sessionId);
if (!owed && !this._termAnimOnSwitch) return;
const el = this.$('terminalContainer');
if (!el) return;
// Restart cleanly when switching tabs faster than the animation runs.
el.classList.remove('term-enter');
void el.offsetWidth;
el.classList.add('term-enter');
clearTimeout(this._termEnterTimer);
const done = () => {
el.classList.remove('term-enter');
clearTimeout(this._termEnterTimer);
};
el.addEventListener('animationend', done, { once: true });
el.addEventListener('animationcancel', done, { once: true });
// Backstop: a backgrounded tab never fires animationend, which would leave
// the pane stuck at its 0% keyframe (invisible) when you come back to it.
this._termEnterTimer = setTimeout(done, this._termAnimDuration() + 900);
},
// ── Windows ───────────────────────────────────────────────────────────────
/**
* True when the window should be parked on its parent tab and flown to its
* resting spot by subagent-windows.js. False for every CSS-animated style,
* which needs the window to start at its final position.
*/
windowEntranceFliesFromTab() {
return (this._winAnimStyle || WIN_ANIM_DEFAULT) === 'fly';
},
/**
* Run the window entrance on an already-positioned window.
* @param {HTMLElement} win a .subagent-window or .ultracode-window
*/
applyWindowEntrance(win) {
if (!win) return;
const style = this._winAnimStyle || WIN_ANIM_DEFAULT;
if (style === 'off' || style === 'fly') return;
// `beam` holds the window still (opacity/filter only) while its line draws
// toward it, then materializes. Everything else starts immediately.
if (style === 'beam') win.style.setProperty('--win-enter-delay', `${BEAM_HOLD_MS / (this._animSpeed || 1)}ms`);
win.classList.add('win-enter');
const done = () => {
win.classList.remove('win-enter');
win.style.removeProperty('--win-enter-delay');
// A transformed window reports a transformed rect, so lines drawn while it
// was animating are slightly off. Redraw once it has settled.
this.updateConnectionLines?.();
};
win.addEventListener('animationend', done, { once: true });
win.addEventListener('animationcancel', done, { once: true });
},
// ── Connection lines ──────────────────────────────────────────────────────
/** Queue an agent's connection line to draw itself on the next line rebuild. */
markConnectionLineEntering(agentId) {
if (!agentId || this._lineAnimStyle === 'off') return;
if (!this._lineEnterActive) return;
if (this._lineEnterActive.has(agentId)) return;
this._lineEnterActive.set(agentId, { startTs: performance.now() });
},
/**
* Attach (or resume) the draw-in animation on freshly rebuilt paths. Called at
* the end of `_updateConnectionLinesImmediate()`, which has just thrown away
* and recreated every path element.
*/
_applyLineEntrances(svg) {
const active = this._lineEnterActive;
if (!active || active.size === 0 || !svg) return;
const style = this._lineAnimStyle || LINE_ANIM_DEFAULT;
if (style === 'off') {
active.clear();
return;
}
const now = performance.now();
const duration = this._lineAnimDuration();
for (const [agentId, state] of active) {
const elapsed = now - state.startTs;
if (elapsed > duration + 50) {
active.delete(agentId);
continue;
}
const path = svg.querySelector(`path[data-agent-id="${CSS.escape(agentId)}"]`);
if (!path) continue;
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
path.classList.add('line-enter');
// `packet` rides a bright dash ON TOP of the normal dashed line, so the base
// line keeps its look instead of being taken over by the animation.
if (style === 'packet') {
const packet = path.cloneNode(false);
packet.removeAttribute('data-agent-id');
packet.setAttribute('class', 'connection-line-packet');
packet.style.setProperty('--line-len', `${len}px`);
packet.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
svg.appendChild(packet);
}
}
},
// ── Lab (compare styles without spawning sessions or agents) ───────────────
/** Floating picker: switch styles per surface and replay fake entrances. */
openAnimLab() {
if (document.getElementById('animLab')) return;
const group = (title, styles, attr) => `
<div class="anim-lab-group">
<div class="anim-lab-group-title">${title}</div>
${styles
.map(
(s) => `<button type="button" class="anim-lab-style" data-attr="${attr}" data-style="${s.key}">
<strong>${escapeHtml(s.label)}</strong><em>${escapeHtml(s.blurb)}</em>
</button>`
)
.join('')}
</div>`;
const panel = document.createElement('div');
panel.id = 'animLab';
panel.className = 'anim-lab';
panel.innerHTML = `
<div class="anim-lab-head">
<span>Entrance lab</span>
<button type="button" class="anim-lab-close" aria-label="Close">&times;</button>
</div>
<div class="anim-lab-themes">
${ANIM_THEMES.map((t) => `<button type="button" data-theme="${t.key}">${escapeHtml(t.label)}</button>`).join('')}
</div>
<div class="anim-lab-scroll">
${group('Tabs', TAB_ANIM_STYLES, 'tab')}
${group('Terminal pane', TERM_ANIM_STYLES, 'term')}
<label class="anim-lab-check">
<input type="checkbox" data-check="termSwitch"> Also on every tab switch
</label>
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
</div>
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
<input type="range" data-range="stagger" min="0" max="260" step="10">
</label>
<label class="anim-lab-range">Speed <output data-out="speed"></output>
<input type="range" data-range="speed" min="0.5" max="2" step="0.1">
</label>
<div class="anim-lab-demo">
<span>Replay</span>
<button type="button" data-demo="tabs">Tabs</button>
<button type="button" data-demo="term">Pane</button>
<button type="button" data-demo="window">Window</button>
<button type="button" data-demo="all">All</button>
</div>
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
`;
document.body.appendChild(panel);
panel.querySelector('.anim-lab-close').addEventListener('click', () => this.closeAnimLab());
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
btn.addEventListener('click', () => {
this.setAnimTheme(btn.dataset.theme);
this._syncAnimLab();
this.demoEntrance('all');
});
});
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.addEventListener('click', () => {
const { attr, style } = btn.dataset;
if (attr === 'tab') this.setTabAnimStyle(style);
else if (attr === 'win') this.setWinAnimStyle(style);
else if (attr === 'term') this.setTermAnimStyle(style);
else this.setLineAnimStyle(style);
this._syncAnimLab();
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
});
});
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
this.setTermAnimOnSwitch(e.target.checked);
});
panel.querySelectorAll('input[data-range]').forEach((input) => {
input.addEventListener('input', () => {
if (input.dataset.range === 'stagger') this.setTabAnimStagger(Number(input.value));
else this.setAnimSpeed(Number(input.value));
this._syncAnimLab();
});
input.addEventListener('change', () => this.demoEntrance('all'));
});
panel.querySelectorAll('button[data-demo]').forEach((btn) => {
btn.addEventListener('click', () => this.demoEntrance(btn.dataset.demo));
});
this._syncAnimLab();
},
closeAnimLab() {
document.getElementById('animLab')?.remove();
this._clearEntranceDemo();
},
_syncAnimLab() {
const panel = document.getElementById('animLab');
if (!panel) return;
const current = {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
};
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
});
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
});
const check = panel.querySelector('input[data-check="termSwitch"]');
if (check) check.checked = !!this._termAnimOnSwitch;
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
panel.querySelector('output[data-out="stagger"]').textContent = `${this._tabAnimStagger}ms`;
panel.querySelector('input[data-range="speed"]').value = String(this._animSpeed);
panel.querySelector('output[data-out="speed"]').textContent = `${this._animSpeed.toFixed(1)}x`;
},
_clearEntranceDemo() {
clearTimeout(this._animDemoTimer);
clearTimeout(this._animDemoWindowTimer);
document.querySelectorAll('.session-tab[data-demo]').forEach((el) => el.remove());
document.querySelectorAll('.subagent-window[data-demo]').forEach((el) => el.remove());
document.getElementById('animLabLines')?.remove();
},
/**
* The demo line gets its OWN svg overlay rather than sharing #connectionLines.
* That overlay is rebuilt from real windows via `svg.innerHTML = ''`, so a fake
* path dropped into it is erased the moment anything triggers a redraw -
* including the demo window's own entrance finishing.
*/
_demoLineSvg() {
let svg = document.getElementById('animLabLines');
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'animLabLines';
svg.setAttribute('class', 'connection-lines-svg');
document.body.appendChild(svg);
}
return svg;
},
/** @param {'tabs'|'term'|'window'|'all'} what */
demoEntrance(what = 'all') {
this._clearEntranceDemo();
// The pane is a real, shared element rather than a throwaway, so replay it
// through the same entry point a real launch uses (bypassing the owed-id
// check, which only exists to keep background sessions from hijacking it).
if (what === 'term' || what === 'all') {
const wasOnSwitch = this._termAnimOnSwitch;
this._termAnimOnSwitch = true;
this.playTerminalEntrance(null);
this._termAnimOnSwitch = wasOnSwitch;
}
if (what === 'term') return;
const tabCount = what === 'window' ? 1 : 4;
const tabs = this._demoTabs(tabCount);
let total = Math.max(this._tabAnimDuration() + tabCount * this._tabAnimStagger, this._termAnimDuration());
if (what !== 'tabs') {
// Give the tab cascade a beat, so the window reads as coming out of it.
const lead = what === 'all' ? Math.min(320, this._tabAnimStagger * 2) : 0;
this._animDemoWindowTimer = setTimeout(() => this._demoWindow(tabs[0]), lead);
total = Math.max(total, lead + this._winAnimDuration() + this._lineAnimDuration() + BEAM_HOLD_MS);
}
this._animDemoTimer = setTimeout(() => this._clearEntranceDemo(), total + 1800);
},
/** Append `count` throwaway tabs and run the tab entrance on them. */
_demoTabs(count) {
const container = this.$('sessionTabs');
if (!container) return [];
const made = [];
const base = this.sessions?.size || 0;
for (let i = 0; i < count; i++) {
const tab = document.createElement('div');
tab.className = 'session-tab';
tab.dataset.demo = '1';
tab.dataset.color = ['green', 'blue', 'purple', 'orange', 'pink', 'yellow', 'red'][i % 7];
tab.innerHTML = `
<span class="tab-number">${base + i + 1}</span>
<span class="tab-status idle" aria-hidden="true"></span>
<span class="tab-info"><span class="tab-name-row">
<span class="tab-name">w${base + i + 1}-demo</span>
</span></span>`;
container.appendChild(tab);
made.push(tab);
if (this._tabAnimStyle !== 'off') {
tab.style.setProperty('--tab-enter-delay', `${i * this._tabAnimStagger}ms`);
void tab.offsetWidth; // force layout so the class add starts a fresh run
tab.classList.add('tab-enter');
}
}
return made;
},
/** Append a throwaway agent window under `originTab`, plus its connection line. */
_demoWindow(originTab) {
const tabRect = originTab?.getBoundingClientRect();
const win = document.createElement('div');
win.className = 'subagent-window';
win.dataset.demo = '1';
win.style.width = '360px';
win.style.height = '220px';
win.style.left = `${Math.max(24, (tabRect?.left ?? 120) - 40)}px`;
win.style.top = `${(tabRect?.bottom ?? 60) + 160}px`;
win.style.zIndex = '1001';
win.innerHTML = `
<div class="subagent-window-header">
<div class="subagent-window-title"><span class="icon">&#129302;</span><span class="id">demo-agent</span>
<span class="status running">running</span></div>
</div>
<div class="subagent-window-body"><div class="subagent-empty">Preview window</div></div>`;
document.body.appendChild(win);
this.applyWindowEntrance(win);
this._demoLine(tabRect, win);
},
/** Draw a fake tab→window line into the lab's own overlay and animate it. */
_demoLine(tabRect, win) {
if (!tabRect) return;
const svg = this._demoLineSvg();
const winRect = win.getBoundingClientRect();
const x1 = tabRect.left + tabRect.width / 2;
const y1 = tabRect.bottom;
const x2 = winRect.left + winRect.width / 2;
const y2 = winRect.top;
const midY = (y1 + y2) / 2;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
path.setAttribute('class', 'connection-line');
path.dataset.demo = '1';
svg.appendChild(path);
if ((this._lineAnimStyle || LINE_ANIM_DEFAULT) === 'off') return;
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', '0ms');
path.classList.add('line-enter');
if (this._lineAnimStyle === 'packet') {
const packet = path.cloneNode(false);
packet.setAttribute('class', 'connection-line-packet');
packet.dataset.demo = '1';
packet.style.setProperty('--line-len', `${len}px`);
packet.style.setProperty('--line-enter-delay', '0ms');
svg.appendChild(packet);
}
},
});
+21
View File
@@ -103,6 +103,7 @@
'Run Claude Code': '运行 Claude Code',
'Run OpenCode': '运行 OpenCode',
'Run Gemini': '运行 Gemini',
'Run Antigravity': '运行 Antigravity',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
@@ -349,6 +350,26 @@
'Show Shortcuts': '显示快捷键',
'Full shortcut reference': '完整快捷键参考',
// Mobile overview (phone home screen)
'Needs you': '需要你',
'Current sessions': '当前会话',
'Past sessions': '历史会话',
'Show all past sessions': '显示全部历史会话',
'Show fewer': '收起',
'Choose what to run': '选择运行方式',
'Web / URL': '网页 / 链接',
'Add URL…': '添加链接…',
'Nothing running. Hit Run to start something.': '当前没有运行中的会话。点击“运行”开始。',
'No past conversations yet': '尚无历史对话',
'Loading…': '加载中…',
// Status pills are deliberately NOT listed: they are single generic words
// ("idle", "done", "error") that also appear as state strings elsewhere, so
// they carry data-i18n-skip in the DOM instead of a translation entry here.
'Overview Home Screen': '概览主页',
'On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen':
'在手机上,点击 C 图标打开会话概览(需要你 / 空间 / 空闲),而不是欢迎页',
Phone: '手机',
// Session/case dialogs
'Session Options': '会话选项',
'Session Name': '会话名称',
+37 -1
View File
@@ -379,6 +379,12 @@
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button>
</div>
</div>
<!-- Mobile Overview (phone home screen, shown in place of the welcome
overlay when the "C" logo is tapped). Ships `hidden`: only
mobile-overview.js removes it, and only when the phone gate passes, so
desktop (which never loads mobile.css) can never render it. -->
<div class="mobile-overview" id="mobileOverview" hidden></div>
</main>
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
@@ -464,6 +470,9 @@
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
<span class="run-mode-dot gemini"></span>Gemini
</button>
<button class="run-mode-option" data-mode="antigravity" onclick="app.setRunMode('antigravity')">
<span class="run-mode-dot antigravity"></span>Antigravity
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell
@@ -733,6 +742,7 @@
<option value="opencode">OpenCode</option>
<option value="codex">Codex</option>
<option value="gemini">Gemini</option>
<option value="antigravity">Antigravity</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -1276,6 +1286,20 @@
</optgroup>
</select>
</div>
<div class="settings-item settings-item-multiline" title="How new session tabs, the terminal pane, agent windows and their connection lines appear. This device only. For per-surface control and a live preview, add ?animlab=1 to the URL.">
<div class="settings-item-text">
<span class="settings-item-label">Entrance Animations</span>
<span class="settings-item-desc">How new tabs, panes and agent windows arrive</span>
</div>
<select id="appSettingsEntranceAnim" class="form-select settings-inline-select">
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="quiet">Quiet</option>
<option value="playful">Playful</option>
<option value="custom">Custom (set in the lab)</option>
</select>
</div>
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
<span class="settings-item-label">WebGL Renderer</span>
<label class="switch switch-sm">
@@ -1424,6 +1448,16 @@
</label>
</div>
<!-- Phone Section (only meaningful under 430px, hidden elsewhere by settings-ui.js) -->
<div class="settings-section-header" id="appSettingsPhoneSection">Phone</div>
<div class="settings-item" id="appSettingsMobileOverviewItem" title="On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen">
<span class="settings-item-label">Overview Home Screen</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsMobileOverview" checked>
<span class="slider"></span>
</label>
</div>
<!-- Tab Bar Section -->
<div class="settings-section-header">Tab Bar</div>
<div class="settings-item" title="Show folder path below tab name and allow tab bar to wrap into multiple rows">
@@ -1848,7 +1882,7 @@
<input type="checkbox" id="eventIdleAudio">
<div class="event-label">Response complete</div>
<input type="checkbox" id="eventStopEnabled" checked>
<input type="checkbox" id="eventStopEnabled">
<input type="checkbox" id="eventStopBrowser">
<input type="checkbox" id="eventStopPush">
<input type="checkbox" id="eventStopAudio">
@@ -2626,6 +2660,8 @@
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
+684
View File
@@ -0,0 +1,684 @@
/**
* @fileoverview Phone home screen: a scrolling overview of what every session is
* doing, shown instead of the welcome overlay when the "C" logo is tapped.
*
* The welcome screen answers "how do I start something"; on a phone the more
* urgent question is "which of my sessions is blocked on me". This surface
* answers that first: NEEDS YOU (pending permission/question/idle hooks and
* errored sessions), then SPACES (cases, expandable to their sessions), then
* WORKING and IDLE / DONE.
*
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
* popped-out solo window, per-device setting on). Tablet and desktop keep the
* welcome overlay untouched. The container ships with the `hidden` attribute and
* only this module removes it, so desktop (which never loads mobile.css) cannot
* render an unstyled overview even if a class rule leaked.
*
* Everything renders from state the page already holds (`this.sessions`,
* `this.cases`, `this.pendingHooks`) — no endpoint, no SSE event, no schema.
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
* @dependency mobile-handlers.js (MobileDetection)
* @dependency session-ui.js (selectQuickStartCase for "New session here")
* @loadorder 12.55 of 16, after webview-tabs.js, before entrance-animations.js
*/
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
/** Sort rank per state: the most demanding thing sorts first inside a section. */
const MOBILE_OVERVIEW_STATE_RANK = {
needs: 0,
error: 1,
waiting: 2,
working: 3,
idle: 4,
done: 5,
};
/** How many past conversations show before the "Show all" toggle. */
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
/**
* Backends offered by the Run picker, mirroring the toolbar's run-mode menu
* (`#runModeMenu` in index.html). `short` is the badge on the Run button itself.
*/
const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'claude', label: 'Claude Code', short: 'Claude' },
{ mode: 'opencode', label: 'OpenCode', short: 'OpenCode' },
{ mode: 'codex', label: 'Codex', short: 'Codex' },
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
const MOBILE_OVERVIEW_PILL_LABEL = {
needs: 'needs you',
error: 'error',
waiting: 'waiting',
working: 'working',
idle: 'idle',
done: 'done',
};
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Model (pure)
// ═══════════════════════════════════════════════════════════════
/**
* Classify one session.
* Order matters: an action hook outranks everything (it is literally blocking
* the agent), and a pending idle_prompt outranks a stale 'busy' status because
* the hook is the newer signal.
* @param {object} session session state from this.sessions
* @param {Set<string>|undefined} hooks pending hook types for that session
* @returns {'needs'|'error'|'waiting'|'working'|'idle'|'done'}
*/
_mobileOverviewState(session, hooks) {
if (hooks && (hooks.has('permission_prompt') || hooks.has('elicitation_dialog'))) return 'needs';
if (session.status === 'error') return 'error';
if (hooks && hooks.has('idle_prompt')) return 'waiting';
if (session.status === 'busy') return 'working';
if (session.status === 'stopped') return 'done';
return 'idle';
},
/**
* Longest-prefix match of a workingDir against the case list, so a session
* started in a subdirectory still belongs to its case. Mirrors the matching in
* `_resolveCaseLabel()` (terminal-ui.js) but returns the case itself.
* @returns {object|null} the matching case, or null when the dir is outside every case
*/
_mobileOverviewCaseFor(workingDir, cases) {
if (!workingDir) return null;
let best = null;
for (const c of cases || []) {
if (!c || !c.path) continue;
if (workingDir === c.path) return c;
if (workingDir.startsWith(c.path + '/') && (!best || c.path.length > best.path.length)) {
best = c;
}
}
return best;
},
/**
* Build the whole overview model. PURE: reads only its argument, touches no DOM
* and no `this` state, so it can be unit-tested against plain objects.
*
* @param {object} input
* @param {Map<string, object>|Array} input.sessions live sessions (this.sessions)
* @param {Array} input.cases case list (this.cases)
* @param {Array<string>} [input.sessionOrder] the user's tab order, used as the tiebreak
* @param {Map<string, Set<string>>} [input.pendingHooks] this.pendingHooks
* @param {Array} [input.history] unified session items (GET /api/sessions/unified)
* @returns {{needsYou: Array, current: Array, past: Array, sessionCount: number}}
*/
buildMobileOverviewModel(input) {
const cases = Array.isArray(input && input.cases) ? input.cases : [];
const order = Array.isArray(input && input.sessionOrder) ? input.sessionOrder : [];
const pendingHooks = (input && input.pendingHooks) || new Map();
const raw = (input && input.sessions) || [];
const sessions = typeof raw.values === 'function' ? Array.from(raw.values()) : Array.from(raw);
const rows = sessions.map((session) => {
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
const orderIndex = order.indexOf(session.id);
return {
id: session.id,
name: this.getSessionName ? this.getSessionName(session) : session.name || session.id.slice(0, 8),
mode: session.mode || 'claude',
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
};
});
const bySeverityThenOrder = (a, b) => {
const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state];
return rank !== 0 ? rank : a.orderIndex - b.orderIndex;
};
const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder);
// Past = conversations from the unified list that are not currently live.
// The endpoint already folds a transcript into its owning session (via the
// claudeSessionId alias map), so a plain id check is enough to avoid listing
// a running session twice.
const liveIds = new Set(rows.map((r) => r.id));
const past = (Array.isArray(input && input.history) ? input.history : [])
.filter((item) => item && item.sessionId && !liveIds.has(item.sessionId))
.map((item) => {
const matched = this._mobileOverviewCaseFor(item.workingDir, cases);
const dir = item.workingDir || '';
// The transcript reader emits the literal "(no content)" for a
// conversation it could not pull a prompt from; that is not a title.
const prompt = (item.firstPrompt || '').trim();
const title = prompt && prompt !== '(no content)' ? prompt : '';
return {
id: item.sessionId,
claudeSessionId: item.claudeSessionId || '',
workingDir: dir,
name: item.name || '',
title: title || item.name || dir.split('/').pop() || item.sessionId.slice(0, 8),
mode: item.mode || 'claude',
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(dir) : dir,
at: item.lastActivityAt || item.createdAt || 0,
};
})
.sort((a, b) => b.at - a.at);
return {
needsYou: inSection(['needs', 'error', 'waiting']),
current: inSection(['working', 'idle', 'done']),
past,
sessionCount: rows.length,
};
},
// ═══════════════════════════════════════════════════════════════
// Gate + visibility
// ═══════════════════════════════════════════════════════════════
/**
* Phone-only gate. Width-driven (not `isHandheldDevice()`): this is a LAYOUT
* decision, and an unfolded foldable with a tablet-width viewport should get
* the tablet welcome screen. Per-device settings identity is a separate
* question and deliberately stays handheld-based.
*/
shouldUseMobileOverview() {
if (this.isSoloWindow) return false;
const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {};
if (settings.mobileOverviewEnabled === false) return false;
if (typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType) {
return MobileDetection.getDeviceType() === 'mobile';
}
return !!(window.matchMedia && window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY).matches);
},
/** True while the overview is the visible home surface. */
isMobileOverviewVisible() {
const el = document.getElementById('mobileOverview');
return !!el && el.classList.contains('visible');
},
showMobileOverview() {
const el = document.getElementById('mobileOverview');
if (!el) return;
el.hidden = false;
el.classList.add('visible');
this._wireMobileOverview(el);
this.renderMobileOverview();
void this.loadMobileOverviewHistory();
},
hideMobileOverview() {
const el = document.getElementById('mobileOverview');
if (!el) return;
this._closeMobileOverviewRunMenu();
el.classList.remove('visible');
el.hidden = true;
},
/**
* Past conversations, fetched once per home-screen visit. The unified list is
* the same source the welcome screen resumes from, so a row resumed here and a
* row resumed there behave identically. Failures leave the section out rather
* than showing an error: the live sessions above it are the important part.
*/
async loadMobileOverviewHistory() {
if (this._mobileOverviewHistoryLoading) return;
this._mobileOverviewHistoryLoading = true;
try {
this._mobileOverviewHistory = await this._fetchUnifiedSessions(60);
} catch (err) {
console.warn('[mobile-overview] history load failed:', err);
this._mobileOverviewHistory = this._mobileOverviewHistory || [];
} finally {
this._mobileOverviewHistoryLoading = false;
if (this.isMobileOverviewVisible()) this.renderMobileOverview();
}
},
/** Re-render only when the surface is actually showing (called from the tab renderer). */
_refreshMobileOverviewIfVisible() {
if (!this.isMobileOverviewVisible()) return;
this._debouncedCall('mobileOverview', () => this.renderMobileOverview(), 150);
},
/**
* One delegated click listener for every row, plus a breakpoint listener so
* rotating or unfolding while on the home screen swaps to the right surface
* instead of stranding a phone layout on a tablet-width viewport.
*/
_wireMobileOverview(el) {
if (this._mobileOverviewWired) return;
this._mobileOverviewWired = true;
el.addEventListener('click', (event) => {
const target = event.target && event.target.closest && event.target.closest('[data-mo-action]');
if (!target) return;
const action = target.dataset.moAction;
if (action === 'session') {
this._closeMobileOverviewRunMenu();
void this.selectSession(target.dataset.moSession);
} else if (action === 'resume') {
this._closeMobileOverviewRunMenu();
void this.resumeMobileOverviewSession(target.dataset.moSession);
} else if (action === 'more-past') {
this._mobileOverviewShowAllPast = !this._mobileOverviewShowAllPast;
this.renderMobileOverview();
} else if (action === 'run') {
this._closeMobileOverviewRunMenu();
void this.run();
} else if (action === 'run-menu') {
this._toggleMobileOverviewRunMenu();
} else if (action === 'run-mode') {
// Picking a backend both selects it (so the Run button keeps meaning what
// you last chose, exactly like the toolbar) and launches it: on a phone
// the pick IS the intent to start.
this._closeMobileOverviewRunMenu();
this.setRunMode(target.dataset.moMode);
void this.run();
} else if (action === 'run-webview') {
this._closeMobileOverviewRunMenu();
void this.openWebviewFromMenu(target.dataset.moWebview);
} else if (action === 'run-add-url') {
this._closeMobileOverviewRunMenu();
this.showWebviewModal();
}
});
if (window.matchMedia) {
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
const onChange = () => {
// Only relevant while a home surface is up; entering a session re-decides
// through hideWelcome()/showWelcome() anyway.
if (this.activeSessionId) return;
if (typeof this.showWelcome === 'function') this.showWelcome();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
},
_toggleMobileOverviewRunMenu() {
this._mobileOverviewRunMenuOpen = !this._mobileOverviewRunMenuOpen;
this.renderMobileOverview();
},
_closeMobileOverviewRunMenu() {
if (!this._mobileOverviewRunMenuOpen) return;
this._mobileOverviewRunMenuOpen = false;
if (this.isMobileOverviewVisible()) this.renderMobileOverview();
},
/**
* Resume a past conversation. Delegates to the same resumeHistorySession() the
* welcome screen's Resume list uses, so name synthesis, envOverrides and the
* resumeSessionId wiring stay in one place.
*/
async resumeMobileOverviewSession(sessionId) {
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
if (!row || !row.workingDir) return;
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined);
},
// ═══════════════════════════════════════════════════════════════
// Render
// ═══════════════════════════════════════════════════════════════
renderMobileOverview() {
const el = document.getElementById('mobileOverview');
if (!el) return;
const model = this.buildMobileOverviewModel({
sessions: this.sessions,
cases: this.cases,
sessionOrder: this.sessionOrder,
pendingHooks: this.pendingHooks,
history: this._mobileOverviewHistory,
});
// Resume needs the workingDir/claudeSessionId off the row the user tapped.
this._mobileOverviewPastRows = model.past;
el.replaceChildren();
el.appendChild(this._buildMobileOverviewTop());
if (model.needsYou.length) {
el.appendChild(
this._buildMobileOverviewSection(
'Needs you',
model.needsYou.length,
model.needsYou.map((r) => this._buildMobileOverviewRow(r))
)
);
}
el.appendChild(
this._buildMobileOverviewSection(
'Current sessions',
model.current.length,
model.current.map((r) => this._buildMobileOverviewRow(r)),
'Nothing running. Hit Run to start something.'
)
);
el.appendChild(
this._buildMobileOverviewSection(
'Past sessions',
model.past.length,
this._buildMobileOverviewPast(model),
this._mobileOverviewHistory ? 'No past conversations yet' : 'Loading…'
)
);
},
/**
* Past conversations, newest first and capped: the unified list can run to
* dozens, and this section sits below the live ones on purpose.
*/
_buildMobileOverviewPast(model) {
const showAll = !!this._mobileOverviewShowAllPast;
const visible = showAll ? model.past : model.past.slice(0, MOBILE_OVERVIEW_PAST_LIMIT);
const children = visible.map((r) => this._buildMobileOverviewPastRow(r));
const hiddenCount = model.past.length - visible.length;
if (hiddenCount > 0 || showAll) {
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'mobile-overview-more';
toggle.dataset.moAction = 'more-past';
const label = document.createElement('span');
label.textContent = showAll ? 'Show fewer' : 'Show all past sessions';
toggle.appendChild(label);
if (!showAll) {
const count = document.createElement('span');
count.className = 'mobile-overview-more-count';
count.setAttribute('data-i18n-skip', '');
count.textContent = String(hiddenCount);
toggle.appendChild(count);
}
children.push(toggle);
}
return children;
},
_buildMobileOverviewTop() {
const wrap = document.createElement('div');
wrap.className = 'mobile-overview-header';
const top = document.createElement('div');
top.className = 'mobile-overview-top';
const brand = document.createElement('span');
brand.className = 'mobile-overview-brand';
brand.textContent = (window.CodemanI18n && window.CodemanI18n.displayName) || 'Codeman';
brand.setAttribute('data-i18n-skip', '');
top.appendChild(brand);
// Split button carrying the TOOLBAR's own classes (`btn-toolbar btn-run
// mode-<mode>` / `btn-run-gear`), so the per-backend gradient, border and
// text color come from the same rules as the Run button in the toolbar and
// stay in sync with it for free. mobile.css only sizes it.
const group = document.createElement('div');
group.className = 'mobile-overview-run-group';
const mode = this.runMode || 'claude';
const run = document.createElement('button');
run.className = `btn-toolbar btn-run mode-${mode} mobile-overview-run`;
run.type = 'button';
run.dataset.moAction = 'run';
const runLabel = document.createElement('span');
runLabel.textContent = 'Run';
run.appendChild(runLabel);
const runMode = document.createElement('span');
runMode.className = 'mobile-overview-run-mode';
runMode.setAttribute('data-i18n-skip', '');
runMode.textContent = MOBILE_OVERVIEW_RUN_MODES.find((m) => m.mode === mode)?.short || mode;
run.appendChild(runMode);
group.appendChild(run);
const caret = document.createElement('button');
caret.className = `btn-toolbar btn-run-gear mode-${mode} mobile-overview-run-caret`;
caret.type = 'button';
caret.dataset.moAction = 'run-menu';
caret.setAttribute('aria-label', 'Choose what to run');
caret.setAttribute('aria-expanded', String(!!this._mobileOverviewRunMenuOpen));
// An SVG chevron, not a "⌄" glyph: the character carries its own baseline
// offset, so it sits visibly low in a flex-centered box no matter what the
// line-height says. A path is centered by geometry. Same shape the toolbar's
// run-mode gear uses.
caret.appendChild(this._buildMobileOverviewChevron());
group.appendChild(caret);
top.appendChild(group);
wrap.appendChild(top);
if (this._mobileOverviewRunMenuOpen) wrap.appendChild(this._buildMobileOverviewRunMenu());
return wrap;
},
/** Down chevron as SVG (see the note at its call site). */
_buildMobileOverviewChevron() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2.5');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-hidden', 'true');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M6 9l6 6 6-6');
svg.appendChild(path);
return svg;
},
/**
* The Run picker: the same backends as the toolbar's run-mode menu, plus saved
* web tabs. Deliberately no "Recent Sessions" block, unlike the toolbar menu:
* past conversations have their own section further down this screen.
*/
_buildMobileOverviewRunMenu() {
const menu = document.createElement('div');
menu.className = 'mobile-overview-run-menu';
const current = this.runMode || 'claude';
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
const option = document.createElement('button');
option.type = 'button';
option.className = 'mobile-overview-run-option' + (entry.mode === current ? ' selected' : '');
option.dataset.moAction = 'run-mode';
option.dataset.moMode = entry.mode;
const dot = document.createElement('span');
dot.className = 'run-mode-dot ' + entry.mode;
dot.setAttribute('aria-hidden', 'true');
option.appendChild(dot);
const label = document.createElement('span');
label.textContent = entry.label;
option.appendChild(label);
menu.appendChild(option);
}
const header = document.createElement('div');
header.className = 'mobile-overview-run-header';
header.textContent = 'Web / URL';
menu.appendChild(header);
for (const webview of this.webviews ? this.webviews.values() : []) {
const option = document.createElement('button');
option.type = 'button';
option.className = 'mobile-overview-run-option';
option.dataset.moAction = 'run-webview';
option.dataset.moWebview = webview.id;
const dot = document.createElement('span');
dot.className = 'run-mode-dot web';
dot.setAttribute('aria-hidden', 'true');
option.appendChild(dot);
const label = document.createElement('span');
// A dashboard name is user content.
label.className = 'case-name';
label.textContent = webview.name;
option.appendChild(label);
menu.appendChild(option);
}
const add = document.createElement('button');
add.type = 'button';
add.className = 'mobile-overview-run-option mobile-overview-run-option--add';
add.dataset.moAction = 'run-add-url';
const addDot = document.createElement('span');
addDot.className = 'run-mode-dot web';
addDot.setAttribute('aria-hidden', 'true');
add.appendChild(addDot);
const addLabel = document.createElement('span');
addLabel.textContent = 'Add URL…';
add.appendChild(addLabel);
menu.appendChild(add);
return menu;
},
_buildMobileOverviewSection(title, count, children, emptyText) {
const section = document.createElement('section');
section.className = 'mobile-overview-section';
const heading = document.createElement('h2');
heading.className = 'mobile-overview-heading';
const label = document.createElement('span');
label.textContent = title;
heading.appendChild(label);
const badge = document.createElement('span');
badge.className = 'mobile-overview-heading-count';
badge.textContent = String(count);
badge.setAttribute('data-i18n-skip', '');
heading.appendChild(badge);
section.appendChild(heading);
if (!children.length && emptyText) {
const empty = document.createElement('p');
empty.className = 'mobile-overview-empty';
empty.textContent = emptyText;
section.appendChild(empty);
return section;
}
for (const child of children) section.appendChild(child);
return section;
},
/**
* A session row. The state class drives the same visual language as the
* session tabs: green dot when it is fine (pulsing while working), a yellow
* blinking row when it wants input, a red blinking row when it asked a
* question. Anything else here would mean two different meanings for the same
* colors on one screen.
*/
_buildMobileOverviewRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
item.dataset.moAction = 'session';
item.dataset.moSession = row.id;
const dot = document.createElement('span');
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
const body = document.createElement('span');
body.className = 'mobile-overview-row-body';
const line1 = document.createElement('span');
line1.className = 'mobile-overview-row-title';
const name = document.createElement('span');
// .session-name is in the i18n skip list: a session name is user content.
name.className = 'session-name';
name.textContent = row.name;
line1.appendChild(name);
if (row.caseName) {
const meta = document.createElement('span');
meta.className = 'mobile-overview-row-case case-name';
meta.textContent = ' · ' + row.caseName;
line1.appendChild(meta);
}
body.appendChild(line1);
const line2 = document.createElement('span');
line2.className = 'mobile-overview-row-sub';
line2.setAttribute('data-i18n-skip', '');
line2.textContent = row.mode + (row.dir ? ' · ' + row.dir : '');
body.appendChild(line2);
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
// Skipped by i18n on purpose: the labels are generic single words ("idle",
// "done", "error") that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', '');
pill.textContent = row.pill;
item.appendChild(pill);
const chevron = document.createElement('span');
chevron.className = 'mobile-overview-chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = '›';
item.appendChild(chevron);
return item;
},
/** A past conversation. Tapping it resumes, which creates a fresh session. */
_buildMobileOverviewPastRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'mobile-overview-row mobile-overview-row--past';
item.dataset.moAction = 'resume';
item.dataset.moSession = row.id;
const dot = document.createElement('span');
dot.className = 'mobile-overview-dot mobile-overview-dot--past';
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
const body = document.createElement('span');
body.className = 'mobile-overview-row-body';
const title = document.createElement('span');
// A first prompt is user content, never app copy.
title.className = 'mobile-overview-row-title session-name';
title.textContent = row.title;
body.appendChild(title);
const sub = document.createElement('span');
sub.className = 'mobile-overview-row-sub';
sub.setAttribute('data-i18n-skip', '');
const when = row.at && this._formatTimeAgo ? this._formatTimeAgo(row.at) : '';
sub.textContent = [row.caseName || row.dir, when].filter(Boolean).join(' · ');
body.appendChild(sub);
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'mobile-overview-pill mobile-overview-pill--past';
pill.textContent = 'resume';
pill.setAttribute('data-i18n-skip', '');
item.appendChild(pill);
const chevron = document.createElement('span');
chevron.className = 'mobile-overview-chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = '›';
item.appendChild(chevron);
return item;
},
});
+458
View File
@@ -839,6 +839,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(96, 165, 250, 0.5);
}
/* Antigravity mode colors on mobile */
.btn-toolbar.btn-run.mode-antigravity,
.btn-toolbar.btn-run-gear.mode-antigravity {
background: #0b2b33;
border-color: rgba(34, 211, 238, 0.3);
color: #cffafe;
}
.btn-toolbar.btn-run.mode-antigravity:active,
.btn-toolbar.btn-run-gear.mode-antigravity:active {
background: #0e7490;
border-color: rgba(34, 211, 238, 0.5);
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -855,6 +869,17 @@ html.mobile-init .file-browser-panel {
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
}
/* Per-URL edit/delete in the Web / URL list need a real touch target, and they
sit next to the row's own tap area, so they get sized up rather than relying
on the 24px desktop hit box. */
.run-mode-row-btn {
width: 34px;
height: 34px;
font-size: 1rem;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
}
.run-mode-webview-delete { font-size: 1.2rem; }
.run-mode-history {
-webkit-overflow-scrolling: touch;
touch-action: manipulation;
@@ -2234,6 +2259,433 @@ html.mobile-init .file-browser-panel {
border-radius: 8px;
transform: none !important;
}
/* ---- Mobile Overview (phone home screen) ----
Shown in place of the welcome overlay when the C logo is tapped. Styled only
with :root tokens so every skin, including the four light ones, works with
no override block. Never give .mobile-overview itself a display value: the
element ships with [hidden] and only .visible may turn it on. */
.mobile-overview.visible {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 0.6rem calc(1rem + var(--safe-area-bottom));
background: var(--bg-dark);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* No side padding: the wordmark's left edge lines up with the session cards
below it, and the Run button's right edge with theirs. */
.mobile-overview-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0;
}
/* Same accent as the "C" in the header (`.logo` uses --accent-hover), and the
same 48px box as the Run button opposite it, so the two are centered on one
line by construction rather than by eye. */
.mobile-overview-brand {
display: inline-flex;
align-items: center;
min-height: 48px;
font-size: 1.5rem;
font-weight: 800;
line-height: 1;
color: var(--accent-hover);
letter-spacing: -0.02em;
}
/* Split Run button. Colors come from the toolbar's own
`.btn-toolbar.btn-run.mode-<backend>` rules in styles.css (the element
carries those classes), so this button and the one in the toolbar are the
same control in two places. Only size/shape is set here, and NOTHING that
would override the mode gradient. */
.mobile-overview-header {
position: relative;
display: flex;
flex-direction: column;
}
.mobile-overview-run-group {
display: flex;
align-items: stretch;
flex-shrink: 0;
}
/* Sized well past the 44px touch minimum: this is the primary action on the
home screen, and the caret is a separate target right next to it.
⚠️ The !important is required, not decorative: the phone block clamps every
.btn-toolbar to `height/min-height/max-height: 26px !important`, and this
button deliberately carries .btn-toolbar to inherit the run-mode gradient.
Without matching !important (max-height included) it renders 26px tall. */
.mobile-overview-run-group .mobile-overview-run {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 48px !important;
height: 48px !important;
max-height: 48px !important;
padding: 0 1.15rem !important;
font-size: 0.95rem !important;
line-height: 1;
font-weight: 700;
font-family: inherit;
border-radius: 10px 0 0 10px !important;
}
.mobile-overview-run-mode {
font-weight: 600;
font-size: 0.85rem;
opacity: 0.8;
}
.mobile-overview-run-group .mobile-overview-run-caret {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px !important;
height: 48px !important;
max-height: 48px !important;
min-width: 48px !important;
width: 48px !important;
padding: 0 !important;
line-height: 1;
font-family: inherit;
border-radius: 0 10px 10px 0 !important;
}
/* The phone block shrinks every run-gear glyph to 10px; this one is a 48px
tap target, so it gets a proportionate chevron. `display:block` drops the
inline-baseline gap that would otherwise push it a pixel low. */
.mobile-overview-run-group .mobile-overview-run-caret svg {
display: block;
width: 20px !important;
height: 20px !important;
margin: 0 !important;
}
.mobile-overview-run-menu {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin: 0.5rem 0 0;
padding: 0.35rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--floating-bg);
box-shadow: var(--elevated-shadow);
}
.mobile-overview-run-option {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
padding: 0 0.6rem;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: 0.82rem;
text-align: left;
}
.mobile-overview-run-option.selected {
background: var(--control-bg-hover);
}
.mobile-overview-run-option:active {
background: var(--bg-hover);
}
.mobile-overview-run-option--add {
color: var(--accent);
}
.mobile-overview-run-header {
padding: 0.4rem 0.6rem 0.2rem;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
.mobile-overview-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
/* Flush left with the wordmark and the cards: one left edge down the screen. */
.mobile-overview-heading {
display: flex;
align-items: center;
gap: 0.35rem;
margin: 0.35rem 0 0;
padding: 0;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
.mobile-overview-heading-count {
color: var(--text-dim);
font-weight: 600;
}
.mobile-overview-empty {
margin: 0;
padding: 0.5rem 0;
font-size: 0.75rem;
color: var(--text-muted);
}
/* Row: 56px tap target, dot + two-line body + pill + chevron */
.mobile-overview-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
min-height: 56px;
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-card);
color: var(--text);
font-family: inherit;
text-align: left;
}
.mobile-overview-row:active {
background: var(--bg-hover);
}
/* Attention states mirror the session tabs exactly: red blink when the agent
asked something (permission / question), yellow blink when it is waiting for
a prompt. Same hues and same cadence as tab-blink-red / tab-blink-yellow in
styles.css; the keyframes are re-declared here only because a tab's resting
background is transparent while a row's is the card color. */
.mobile-overview-row--needs {
border-color: var(--red);
animation: mobile-overview-blink-red 2.5s ease-in-out infinite;
}
.mobile-overview-row--waiting {
border-color: var(--yellow);
animation: mobile-overview-blink-yellow 3.5s ease-in-out infinite;
}
.mobile-overview-row--error {
border-color: var(--red);
}
@keyframes mobile-overview-blink-red {
0%,
100% {
background: var(--bg-card);
border-color: var(--border);
}
50% {
background: rgba(239, 68, 68, 0.14);
border-color: var(--red);
}
}
@keyframes mobile-overview-blink-yellow {
0%,
100% {
background: var(--bg-card);
border-color: var(--border);
}
50% {
background: rgba(234, 179, 8, 0.12);
border-color: var(--yellow);
}
}
.mobile-overview-row-body {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
min-width: 0;
}
.mobile-overview-row-title {
display: block;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-overview-row-case {
color: var(--text-dim);
font-weight: 500;
}
.mobile-overview-row-sub {
display: block;
font-size: 0.68rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-overview-dot {
flex-shrink: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--text-muted);
}
.mobile-overview-dot--needs,
.mobile-overview-dot--error {
background: var(--red);
}
.mobile-overview-dot--waiting {
background: var(--yellow);
}
/* Same as .session-tab .tab-status: green when the session is fine, and the
shared `pulse` keyframes while it is working. */
.mobile-overview-dot--working {
background: var(--green);
animation: pulse 1.5s infinite;
will-change: opacity;
}
.mobile-overview-dot--idle {
background: var(--green);
}
.mobile-overview-dot--done {
background: var(--text-muted);
opacity: 0.5;
}
.mobile-overview-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.45rem;
border: 1px solid var(--border-light);
border-radius: 999px;
font-size: 0.62rem;
font-weight: 600;
color: var(--text-dim);
white-space: nowrap;
}
.mobile-overview-pill--needs,
.mobile-overview-pill--error {
border-color: var(--red);
color: var(--red);
}
.mobile-overview-pill--waiting {
border-color: var(--yellow);
color: var(--yellow);
}
.mobile-overview-pill--idle,
.mobile-overview-pill--working {
border-color: var(--green);
color: var(--green);
}
.mobile-overview-chevron {
flex-shrink: 0;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
}
/* Past conversations: quieter than a live row, since tapping one starts work */
.mobile-overview-row--past {
background: transparent;
border-style: dashed;
}
.mobile-overview-row--past .mobile-overview-row-title {
font-weight: 500;
color: var(--text-dim);
}
.mobile-overview-dot--past {
background: transparent;
border: 1px solid var(--border-light);
}
.mobile-overview-pill--past {
border-color: var(--border-light);
color: var(--text-muted);
}
.mobile-overview-more {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 40px;
border: none;
border-radius: 10px;
background: var(--control-bg);
color: var(--text-dim);
font-family: inherit;
font-size: 0.72rem;
font-weight: 600;
}
.mobile-overview-more-count {
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: var(--control-bg-hover);
color: var(--text-muted);
}
}
/* The overview's attention blink is an alert, so it stays visible without
motion: hold the alert color instead of animating to it. */
@media (prefers-reduced-motion: reduce) {
.mobile-overview-row--needs,
.mobile-overview-row--waiting {
animation: none;
}
.mobile-overview-row--needs {
background: rgba(239, 68, 68, 0.14);
}
.mobile-overview-row--waiting {
background: rgba(234, 179, 8, 0.12);
}
.mobile-overview-dot--working {
animation: none;
}
}
/* Light-skin compatibility for mobile-only chrome. These components predate
@@ -2275,6 +2727,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-antigravity, .btn-toolbar.btn-run-gear.mode-antigravity) {
background: linear-gradient(135deg, #0e7490, #0891b2);
border-color: #155e75;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
+97 -42
View File
@@ -9,7 +9,7 @@
* 5. Audio alerts (Web Audio API beep, user-opt-in)
*
* Features:
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v4 migration
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v5 migration
* - Device-specific defaults (notifications disabled on mobile by default)
* - 5s notification grouping window to batch rapid-fire events
* - 100-notification cap with oldest eviction
@@ -21,7 +21,7 @@
* @param {CodemanApp} app - Reference to the main app instance
*
* @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
* @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults)
* @dependency mobile-handlers.js (MobileDetection stable handheld identity/device type)
* @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js
*/
@@ -65,12 +65,19 @@ class NotificationManager {
});
}
loadPreferences() {
_usesMobilePreferences() {
return (
MobileDetection.isHandheldDevice?.() ??
MobileDetection.getDeviceType() === 'mobile'
);
}
getDefaultPreferences() {
const defaultEventTypes = {
permission_prompt: { enabled: true, browser: true, audio: true, push: false },
elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
idle_prompt: { enabled: true, browser: true, audio: false, push: false },
stop: { enabled: true, browser: false, audio: false, push: false },
stop: { enabled: false, browser: false, audio: false, push: false },
session_error: { enabled: true, browser: true, audio: false, push: false },
respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
token_milestone: { enabled: true, browser: false, audio: false, push: false },
@@ -80,8 +87,8 @@ class NotificationManager {
};
// Device-specific defaults: mobile has notifications disabled by default
const isMobile = MobileDetection.getDeviceType() === 'mobile';
const defaults = {
const isMobile = this._usesMobilePreferences();
return {
enabled: !isMobile, // Disabled on mobile by default
browserNotifications: !isMobile,
audioAlerts: false,
@@ -92,51 +99,97 @@ class NotificationManager {
muteInfo: false,
// Per-event-type preferences
eventTypes: defaultEventTypes,
_version: 4,
_version: 5,
};
}
/**
* Apply the complete v1→v5 migration to either local or server-hydrated
* preferences. Keeping one normalization path prevents fresh browsers from
* reviving retired drawer-only hook defaults.
*/
normalizePreferences(rawPreferences) {
const defaults = this.getDefaultPreferences();
if (
!rawPreferences ||
typeof rawPreferences !== 'object' ||
Array.isArray(rawPreferences)
) {
return defaults;
}
const prefs = {
...rawPreferences,
eventTypes:
rawPreferences.eventTypes &&
typeof rawPreferences.eventTypes === 'object' &&
!Array.isArray(rawPreferences.eventTypes)
? Object.fromEntries(
Object.entries(rawPreferences.eventTypes).map(([key, value]) => [
key,
value && typeof value === 'object' ? { ...value } : value,
])
)
: undefined,
};
const version = Number.isInteger(prefs._version) ? prefs._version : 0;
// Migrate: v1 had browserNotifications defaulting to false
if (version < 2) {
prefs.browserNotifications = true;
}
// Migrate: v2 -> v3 adds eventTypes
if (version < 3) {
prefs.eventTypes = { ...defaults.eventTypes };
}
// Migrate: v3 -> v4 adds push field to all eventTypes
if (version < 4 && prefs.eventTypes) {
for (const key of Object.keys(prefs.eventTypes)) {
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
prefs.eventTypes[key].push = false;
}
}
}
// Migrate: v4 -> v5 removes the drawer-only Response Complete default.
// Preserve users who opted into any external delivery channel.
if (version < 5) {
const stopPref = prefs.eventTypes?.stop;
if (
stopPref?.enabled === true &&
!stopPref.browser &&
!stopPref.audio &&
!stopPref.push
) {
stopPref.enabled = false;
}
}
return {
...defaults,
...prefs,
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
_version: 5,
};
}
loadPreferences() {
try {
const storageKey = this.getStorageKey();
const saved = localStorage.getItem(storageKey);
if (saved) {
const prefs = JSON.parse(saved);
// Migrate: v1 had browserNotifications defaulting to false
if (!prefs._version || prefs._version < 2) {
prefs.browserNotifications = true;
prefs._version = 2;
}
// Migrate: v2 -> v3 adds eventTypes
if (prefs._version < 3) {
prefs.eventTypes = defaultEventTypes;
prefs._version = 3;
localStorage.setItem(storageKey, JSON.stringify(prefs));
}
// Migrate: v3 -> v4 adds push field to all eventTypes
if (prefs._version < 4) {
if (prefs.eventTypes) {
for (const key of Object.keys(prefs.eventTypes)) {
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
prefs.eventTypes[key].push = false;
}
}
}
prefs._version = 4;
localStorage.setItem(storageKey, JSON.stringify(prefs));
}
// Merge with defaults to ensure all eventTypes exist
return {
...defaults,
...prefs,
eventTypes: { ...defaultEventTypes, ...prefs.eventTypes },
};
const normalized = this.normalizePreferences(JSON.parse(saved));
localStorage.setItem(storageKey, JSON.stringify(normalized));
return normalized;
}
} catch (_e) { /* ignore */ }
return defaults;
return this.getDefaultPreferences();
}
// Get storage key for notification prefs (device-specific)
getStorageKey() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
return isMobile ? 'codeman-notification-prefs-mobile' : 'codeman-notification-prefs';
return this._usesMobilePreferences()
? 'codeman-notification-prefs-mobile'
: 'codeman-notification-prefs';
}
savePreferences() {
@@ -163,8 +216,10 @@ class NotificationManager {
'exit-gate': 'ralph_complete',
'subagent-spawn': 'subagent_spawn',
'subagent-complete': 'subagent_complete',
'hook-teammate-idle': 'idle_prompt',
'hook-task-completed': 'stop',
// Team lifecycle hooks are agent activity, not session-idle/stop alerts.
// Reuse the existing opt-in agent categories instead of making them noisy.
'hook-teammate-idle': 'subagent_spawn',
'hook-task-completed': 'subagent_complete',
};
const eventTypeKey = categoryToEventType[category] || category;
+1 -1
View File
@@ -426,7 +426,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
+12
View File
@@ -848,6 +848,18 @@ Object.assign(CodemanApp.prototype, {
},
closeSessionOptions() {
// Commit the field the user was still editing BEFORE editingSessionId is
// cleared. The Session Name input saves on blur (and the auto-compact prompt
// on change), and every autosave handler bails out on `!this.editingSessionId`.
// Hiding the modal blurs the focused input on its own, but that happens after
// the id is gone, so Escape / backdrop-click silently dropped what was typed.
// (Clicking the X worked only because mousedown blurs the input first.)
const modal = document.getElementById('sessionOptionsModal');
const focused = document.activeElement;
if (focused && modal && modal.contains(focused) && typeof focused.blur === 'function') {
focused.blur();
}
this.editingSessionId = null;
// Stop run summary auto-refresh if it was running
this.stopRunSummaryAutoRefresh();
+65 -6
View File
@@ -316,6 +316,9 @@ Object.assign(CodemanApp.prototype, {
select.dataset.listenerAdded = 'true';
}
this.setupQuickStartCasePicker();
// The phone overview labels rows with their case name, and a case rename or
// link does not go through the session-tab renderer.
this._refreshMobileOverviewIfVisible?.();
} catch (err) {
console.error('Failed to load cases:', err);
}
@@ -370,7 +373,7 @@ Object.assign(CodemanApp.prototype, {
this._renderSessionTabsImmediate?.();
},
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
/** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */
async run() {
if (this._runInFlight) return;
@@ -394,6 +397,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'gemini') {
return await this.runGemini();
}
if (mode === 'antigravity') {
return await this.runAntigravity();
}
if (mode === 'shell') {
return await this.runShell();
}
@@ -503,7 +509,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'shell' ? 'Run SH' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
@@ -746,7 +752,7 @@ Object.assign(CodemanApp.prototype, {
// is shared by sibling sessions, so create-with-false must not yank it
// — see the comment in session-routes create). Disabling the setting
// removes it via the App Settings toggle path (system-routes), not here.
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
statusLineTelemetry: this.planUsageChipEnabled(globalSettings),
})
}).then(r => r.json())
);
@@ -1107,6 +1113,57 @@ Object.assign(CodemanApp.prototype, {
}
},
async runAntigravity() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting Antigravity session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/antigravity/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m Antigravity CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash\x1b[0m');
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'antigravity',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
antigravityConfig: { dangerouslySkipPermissions: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Antigravity');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -1119,7 +1176,7 @@ Object.assign(CodemanApp.prototype, {
this.editingSessionId = sessionId;
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -1149,7 +1206,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for external CLI sessions
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
@@ -2569,6 +2626,8 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'claude' ? mode : 'claude';
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'claude'
? mode
: 'claude';
},
});
+58 -13
View File
@@ -312,6 +312,10 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
// Entrance animations. Deliberately NOT part of the settings payload: the
// styles persist to their own localStorage keys via setAnimTheme(), which
// keeps them per-device without touching the .strict() SettingsUpdateSchema.
this._syncEntranceAnimSetting?.();
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
// the toggle there so it can't promise something that won't apply).
document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true;
@@ -325,9 +329,19 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
// Phone overview home screen: only meaningful under 430px, so the row is
// hidden elsewhere rather than offering a toggle that changes nothing.
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
const phoneOnly = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem');
if (mobileOverviewItem) mobileOverviewItem.style.display = phoneOnly;
const phoneSection = document.getElementById('appSettingsPhoneSection');
if (phoneSection) phoneSection.style.display = phoneOnly;
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
// in the template, so an unchecked box and a hidden button stay consistent).
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
@@ -408,7 +422,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false;
// Response complete (stop)
const stopPref = eventTypes.stop || {};
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true;
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? false;
document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false;
document.getElementById('eventStopPush').checked = stopPref.push ?? false;
document.getElementById('eventStopAudio').checked = stopPref.audio ?? false;
@@ -1447,6 +1461,7 @@ Object.assign(CodemanApp.prototype, {
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
@@ -1587,7 +1602,7 @@ Object.assign(CodemanApp.prototype, {
audio: document.getElementById('eventSubagentAudio').checked,
},
},
_version: 4,
_version: 5,
};
if (this.notificationManager) {
this.notificationManager.preferences = notifPrefsToSave;
@@ -1610,6 +1625,10 @@ Object.assign(CodemanApp.prototype, {
// Apply CJK input visibility immediately
this._updateCjkInputState();
// The phone home surface (overview vs welcome) may have just been toggled.
// Only re-decide while a home screen is actually up.
if (!this.activeSessionId) this.showWelcome();
// Apply keyboard bar mode
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
@@ -1640,6 +1659,8 @@ Object.assign(CodemanApp.prototype, {
showSessionButton: _ssb,
showAwayDigestButton: _adb,
showCronButton: _crb,
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
mobileOverviewEnabled: _mov,
...serverSettings
} = settings;
try {
@@ -1798,6 +1819,9 @@ Object.assign(CodemanApp.prototype, {
showUltracodeAgents: false,
ultracodeFloatingWindows: false,
showMultiMonitorButton: false,
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
// OFF so the phone header stays minimal and the mobile-header-buttons
// policy guard keeps passing.
showPlanUsageLimits: false,
showAttachmentsButton: false,
showFileViewerButton: false,
@@ -1805,6 +1829,10 @@ Object.assign(CodemanApp.prototype, {
showSessionButton: false,
showAwayDigestButton: false,
showCronButton: false,
// Phone home screen: the C logo opens the session overview instead of the
// welcome screen. ON by default here, and the escape hatch if it ever
// misbehaves on a device (the gate treats only an explicit false as off).
mobileOverviewEnabled: true,
// Remote auto-reconnect (COD-108) — on by default
remoteAutoReconnect: true,
// Input
@@ -1889,6 +1917,18 @@ Object.assign(CodemanApp.prototype, {
}
},
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
// handhelds default OFF (the mobile block in getDefaultSettings() sets false,
// and the mobile-header-buttons-policy guard depends on that staying false).
// Single source of truth for THREE call sites that must never disagree: the
// App Settings checkbox, the chip's visibility, and the statusLineTelemetry
// flag sent on session create. A chip shown without telemetry renders "—"
// forever, which is exactly the drift this helper prevents.
planUsageChipEnabled(settings = null) {
const s = settings ?? this.loadAppSettingsFromStorage();
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -1967,11 +2007,13 @@ Object.assign(CodemanApp.prototype, {
ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents);
}
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
// Limits"). Server renders the initial state on reload; this handles a live
// toggle from a settings save. Marker class (base is display:inline-flex
// !important), matching the response-viewer/multimonitor pattern.
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
// Settings → Display → "Plan Usage Limits"). The template always ships it
// hidden because display is per-device and the server cannot know a
// localStorage value, so THIS is what reveals it on every load as well as
// on a live toggle. Marker class (base is display:inline-flex !important),
// matching the response-viewer/multimonitor pattern.
const showPlanUsageLimits = this.planUsageChipEnabled(settings);
const planUsageChip = document.getElementById('planUsageChip');
if (planUsageChip) {
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
@@ -2246,10 +2288,12 @@ Object.assign(CodemanApp.prototype, {
'language',
'terminalWheelLocalScrollback',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
'mobileOverviewEnabled',
]);
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
// can show it while mobile stays hidden. It used to sync, so an older
// server.json may still carry `true` — drop it so the server value is NEVER
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. It
// used to sync, so an older server.json may still carry a value — drop it
// so the server value is NEVER
// seeded into a device that didn't explicitly enable it (collection is handled
// separately via the statusLineTelemetry action, not this display flag).
delete appSettings.showPlanUsageLimits;
@@ -2275,7 +2319,8 @@ Object.assign(CodemanApp.prototype, {
if (notificationPreferences && this.notificationManager) {
const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey());
if (!localNotifPrefs) {
this.notificationManager.preferences = notificationPreferences;
this.notificationManager.preferences =
this.notificationManager.normalizePreferences(notificationPreferences);
this.notificationManager.savePreferences();
}
}
+760 -5
View File
@@ -328,7 +328,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.search-filter-chip.active,
.search-badge-session,
.history-view-all-btn,
.session-tab .tab-mode.gemini
.session-tab .tab-mode.gemini,
.session-tab .tab-mode.antigravity
) {
color: var(--accent-d);
}
@@ -638,6 +639,674 @@ body {
100% { box-shadow: 0 0 0px 0px rgba(34, 197, 94, 0); background: transparent; }
}
/* ── Tab entrance animations (tab-entrance.js) ─────────────────────────────
Style is picked by html[data-tab-anim]; the stagger arrives as an inline
--tab-enter-delay per tab (negative when an entrance is resuming after a
re-render). Duration is scaled by --anim-enter-scale so one slider retimes
every style.
Colour/glow effects live on ::before, never on the tab itself: .session-tab.active
sets background/border/box-shadow with !important (and a newly created session is
normally the active one), and !important beats an animation. Transform and opacity
are unaffected, so the tab keeps those. */
.session-tab.tab-enter {
animation-delay: var(--tab-enter-delay, 0ms);
animation-fill-mode: both;
will-change: transform, opacity;
}
.session-tab.tab-enter::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
pointer-events: none;
opacity: 0;
animation-delay: var(--tab-enter-delay, 0ms);
animation-fill-mode: both;
}
/* Slide, drifts in from the right and settles. */
html[data-tab-anim="slide"] .session-tab.tab-enter {
animation-name: tab-enter-slide;
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes tab-enter-slide {
from { opacity: 0; transform: translate3d(24px, 3px, 0) scale(0.96); }
to { opacity: 1; transform: none; }
}
/* Pop, springs past full size, then settles back. */
html[data-tab-anim="pop"] .session-tab.tab-enter {
animation-name: tab-enter-pop;
animation-duration: calc(460ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tab-enter-pop {
0% { opacity: 0; transform: scale(0.4); }
55% { opacity: 1; transform: scale(1.13); }
76% { opacity: 1; transform: scale(0.965); }
100% { opacity: 1; transform: scale(1); }
}
/* CRT, snaps open as a hot line, then unfolds vertically. */
html[data-tab-anim="crt"] .session-tab.tab-enter {
animation-name: tab-enter-crt;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-tab-anim="crt"] .session-tab.tab-enter::before {
animation-name: tab-enter-crt-flash;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tab-enter-crt {
0% { opacity: 0; transform: scale3d(0.04, 0.09, 1); }
20% { opacity: 1; transform: scale3d(1, 0.09, 1); }
56% { opacity: 1; transform: scale3d(1, 1.14, 1); }
78% { opacity: 1; transform: scale3d(1, 0.95, 1); }
100% { opacity: 1; transform: none; }
}
@keyframes tab-enter-crt-flash {
0% { opacity: 1; background: rgba(205, 255, 225, 0.95); box-shadow: 0 0 26px 8px rgba(0, 255, 102, 0.6); }
20% { opacity: 0.9; background: rgba(150, 255, 195, 0.7); box-shadow: 0 0 20px 6px rgba(0, 255, 102, 0.45); }
60% { opacity: 0.32; background: rgba(0, 255, 102, 0.14); box-shadow: 0 0 12px 2px rgba(0, 255, 102, 0.22); }
100% { opacity: 0; background: transparent; box-shadow: none; }
}
/* Unroll, the strip makes room and the tab widens into it. */
html[data-tab-anim="unroll"] .session-tab.tab-enter {
animation-name: tab-enter-unroll;
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
}
@keyframes tab-enter-unroll {
0% { max-width: 0; opacity: 0; padding-left: 0; padding-right: 0; transform: translateY(3px); }
30% { opacity: 1; }
100% { max-width: 900px; opacity: 1; padding-left: 0.6rem; padding-right: 0.6rem; transform: none; }
}
/* Boot, flickers on under a green scan sweep, like a display warming up. */
html[data-tab-anim="boot"] .session-tab.tab-enter {
animation-name: tab-enter-boot;
animation-duration: calc(720ms * var(--anim-enter-scale, 1));
animation-timing-function: linear;
}
html[data-tab-anim="boot"] .session-tab.tab-enter::before {
background: linear-gradient(
100deg,
transparent 0%,
rgba(0, 255, 102, 0.06) 34%,
rgba(200, 255, 220, 0.5) 50%,
rgba(0, 255, 102, 0.06) 66%,
transparent 100%
);
background-size: 280% 100%;
animation-name: tab-enter-boot-scan;
animation-duration: calc(720ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-in-out;
}
@keyframes tab-enter-boot {
0% { opacity: 0; transform: translate3d(0, 5px, 0); }
8% { opacity: 0.85; }
15% { opacity: 0.12; }
23% { opacity: 1; }
31% { opacity: 0.35; }
42% { opacity: 1; transform: none; }
55% { opacity: 0.72; }
66% { opacity: 1; }
100% { opacity: 1; transform: none; }
}
@keyframes tab-enter-boot-scan {
0% { opacity: 0; background-position: 150% 0; }
12% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; background-position: -50% 0; }
}
/* Flip, drops in as a card hinged on its top edge. */
html[data-tab-anim="flip"] .session-tab.tab-enter {
animation-name: tab-enter-flip;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
transform-origin: 50% 0%;
}
@keyframes tab-enter-flip {
0% { opacity: 0; transform: perspective(700px) rotateX(-92deg); }
55% { opacity: 1; transform: perspective(700px) rotateX(13deg); }
78% { opacity: 1; transform: perspective(700px) rotateX(-5deg); }
100% { opacity: 1; transform: perspective(700px) rotateX(0deg); }
}
/* ── Entrance lab (?animlab=1) ───────────────────────────────────────────── */
.anim-lab {
position: fixed;
right: 14px;
bottom: 14px;
z-index: 3100;
width: 300px;
max-height: 88vh;
display: flex;
flex-direction: column;
padding: 10px 12px 12px;
border: 1px solid var(--border-light);
border-radius: 10px;
background: rgba(12, 16, 14, 0.96);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
color: var(--text);
font-size: 0.72rem;
backdrop-filter: blur(6px);
}
.anim-lab-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-weight: 600;
letter-spacing: 0.02em;
}
.anim-lab-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.anim-lab-close:hover { color: var(--text); }
.anim-lab-themes {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 10px;
}
.anim-lab-themes button {
padding: 3px 8px;
border: 1px solid var(--border-light);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--text-dim);
font-size: 0.66rem;
cursor: pointer;
}
.anim-lab-themes button:hover { background: rgba(34, 197, 94, 0.16); color: var(--text); }
.anim-lab-themes button.selected {
border-color: rgba(0, 255, 102, 0.7);
background: rgba(34, 197, 94, 0.2);
color: #fff;
}
.anim-lab-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-bottom: 8px;
}
.anim-lab-scroll::-webkit-scrollbar { width: 5px; }
.anim-lab-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.anim-lab-group { margin-bottom: 10px; }
.anim-lab-group-title {
margin-bottom: 4px;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.anim-lab-style {
display: flex;
flex-direction: column;
gap: 1px;
width: 100%;
margin-bottom: 3px;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-dim);
text-align: left;
cursor: pointer;
}
.anim-lab-style:hover { background: rgba(34, 197, 94, 0.09); color: var(--text); }
.anim-lab-style.selected {
border-color: rgba(0, 255, 102, 0.6);
background: rgba(34, 197, 94, 0.14);
color: #fff;
}
.anim-lab-style strong { font-size: 0.73rem; font-weight: 600; }
.anim-lab-style em { font-style: normal; font-size: 0.64rem; opacity: 0.7; }
.anim-lab-range {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 4px 8px;
margin-bottom: 6px;
color: var(--text-dim);
}
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
.anim-lab-demo {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
color: var(--text-dim);
}
.anim-lab-demo button {
flex: 1;
padding: 4px 0;
border: 1px solid var(--border-light);
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: var(--text);
cursor: pointer;
}
.anim-lab-demo button:hover { background: rgba(34, 197, 94, 0.16); border-color: rgba(0, 255, 102, 0.5); }
.anim-lab-hint { margin: 8px 0 0; font-size: 0.64rem; opacity: 0.55; }
/* ── Window entrance animations ────────────────────────────────────────────
Applied to a window already sitting at its resting position. The `fly` style
is NOT here: it is the pre-existing JS transition in subagent-windows.js that
flies the window out of its parent tab, and it skips this class entirely. */
.subagent-window.win-enter,
.ultracode-window.win-enter {
animation-delay: var(--win-enter-delay, 0ms);
animation-fill-mode: both;
will-change: transform, opacity, filter;
}
.subagent-window.win-enter::before,
.ultracode-window.win-enter::before {
content: "";
position: absolute;
inset: 0;
z-index: 5;
border-radius: inherit;
pointer-events: none;
opacity: 0;
animation-delay: var(--win-enter-delay, 0ms);
animation-fill-mode: both;
}
/* CRT, bursts open as a hot line, then unfolds vertically. */
html[data-win-anim="crt"] .subagent-window.win-enter,
html[data-win-anim="crt"] .ultracode-window.win-enter {
animation-name: win-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-win-anim="crt"] .subagent-window.win-enter::before,
html[data-win-anim="crt"] .ultracode-window.win-enter::before {
animation-name: win-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes win-enter-crt {
0% { opacity: 0; transform: scale3d(0.55, 0.008, 1); }
22% { opacity: 1; transform: scale3d(1, 0.01, 1); }
58% { opacity: 1; transform: scale3d(1, 1.05, 1); }
80% { opacity: 1; transform: scale3d(1, 0.98, 1); }
100% { opacity: 1; transform: none; }
}
@keyframes win-enter-crt-flash {
0% { opacity: 1; background: rgba(205, 255, 225, 0.9); box-shadow: 0 0 40px 14px rgba(0, 255, 102, 0.55); }
22% { opacity: 0.8; background: rgba(120, 255, 180, 0.5); box-shadow: 0 0 30px 10px rgba(0, 255, 102, 0.4); }
60% { opacity: 0.25; background: rgba(0, 255, 102, 0.1); box-shadow: 0 0 14px 3px rgba(0, 255, 102, 0.18); }
100% { opacity: 0; background: transparent; box-shadow: none; }
}
/* Materialize, resolves out of a blur with a short glitch. */
html[data-win-anim="materialize"] .subagent-window.win-enter,
html[data-win-anim="materialize"] .ultracode-window.win-enter {
animation-name: win-enter-materialize;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes win-enter-materialize {
0% { opacity: 0; transform: scale(0.94); filter: blur(16px) brightness(1.7); }
35% { opacity: 0.85; filter: blur(6px) brightness(1.3); }
45% { opacity: 0.4; }
55% { opacity: 0.95; filter: blur(3px) brightness(1.15); }
70% { opacity: 0.75; }
100% { opacity: 1; transform: none; filter: none; }
}
/* Unfold, hinges down from the top edge. */
html[data-win-anim="unfold"] .subagent-window.win-enter,
html[data-win-anim="unfold"] .ultracode-window.win-enter {
animation-name: win-enter-unfold;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
transform-origin: 50% 0%;
}
@keyframes win-enter-unfold {
0% { opacity: 0; transform: perspective(1600px) rotateX(-88deg); }
55% { opacity: 1; transform: perspective(1600px) rotateX(9deg); }
80% { opacity: 1; transform: perspective(1600px) rotateX(-3deg); }
100% { opacity: 1; transform: perspective(1600px) rotateX(0deg); }
}
/* Beam down, holds still (so its connection line can draw toward a stable rect),
then materializes out of a green wash. NO transform: a transformed window
reports a transformed rect and the line would aim at the wrong place. */
html[data-win-anim="beam"] .subagent-window.win-enter,
html[data-win-anim="beam"] .ultracode-window.win-enter {
animation-name: win-enter-beam;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
html[data-win-anim="beam"] .subagent-window.win-enter::before,
html[data-win-anim="beam"] .ultracode-window.win-enter::before {
animation-name: win-enter-beam-wash;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes win-enter-beam {
0% { opacity: 0; filter: brightness(2.6) saturate(0.4); }
20% { opacity: 0.55; }
32% { opacity: 0.2; }
48% { opacity: 0.9; filter: brightness(1.5) saturate(0.8); }
100% { opacity: 1; filter: none; }
}
@keyframes win-enter-beam-wash {
0% { opacity: 0.9; background: linear-gradient(180deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0)); }
45% { opacity: 0.5; background: linear-gradient(180deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0)); }
100% { opacity: 0; background: transparent; }
}
/* Pop, springs open from the centre. */
html[data-win-anim="pop"] .subagent-window.win-enter,
html[data-win-anim="pop"] .ultracode-window.win-enter {
animation-name: win-enter-pop;
animation-duration: calc(460ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes win-enter-pop {
0% { opacity: 0; transform: scale(0.55); }
58% { opacity: 1; transform: scale(1.045); }
80% { opacity: 1; transform: scale(0.99); }
100% { opacity: 1; transform: scale(1); }
}
/* ── Main terminal pane entrance animations ────────────────────────────────
⚠ transform / opacity / clip-path ONLY. xterm's FitAddon derives rows+cols
from getComputedStyle(parent).width/height, the untransformed layout box -
so these are invisible to it, but animating width/height/padding here would
resize the PTY mid-animation. Colour washes go on ::before, never as a
`filter` on the container: that would blur a full-screen WebGL canvas every
frame. `will-change` is deliberately not set, the base rule needs its
`will-change: contents` for terminal compositing. */
.terminal-container.term-enter {
animation-fill-mode: both;
}
.terminal-container.term-enter::before {
content: "";
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
opacity: 0;
animation-fill-mode: both;
}
/* CRT, power-on: a hot line that expands to full height. */
html[data-term-anim="crt"] .terminal-container.term-enter {
animation-name: term-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-term-anim="crt"] .terminal-container.term-enter::before {
animation-name: term-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes term-enter-crt {
0% { opacity: 0; transform: scale3d(0.6, 0.004, 1); }
20% { opacity: 1; transform: scale3d(1, 0.006, 1); }
58% { opacity: 1; transform: scale3d(1, 1.03, 1); }
80% { opacity: 1; transform: scale3d(1, 0.99, 1); }
100% { opacity: 1; transform: none; }
}
@keyframes term-enter-crt-flash {
0% { opacity: 1; background: rgba(210, 255, 230, 0.85); }
20% { opacity: 0.7; background: rgba(120, 255, 180, 0.4); }
60% { opacity: 0.2; background: rgba(0, 255, 102, 0.08); }
100% { opacity: 0; background: transparent; }
}
/* Boot, flickers on under a green scan sweep. */
html[data-term-anim="boot"] .terminal-container.term-enter {
animation-name: term-enter-boot;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: linear;
}
html[data-term-anim="boot"] .terminal-container.term-enter::before {
background: linear-gradient(
180deg,
transparent 0%,
rgba(0, 255, 102, 0.05) 40%,
rgba(200, 255, 220, 0.28) 50%,
rgba(0, 255, 102, 0.05) 60%,
transparent 100%
);
background-size: 100% 300%;
animation-name: term-enter-boot-scan;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-in-out;
}
@keyframes term-enter-boot {
0% { opacity: 0; transform: scale(0.995); }
8% { opacity: 0.85; }
15% { opacity: 0.1; }
24% { opacity: 1; }
33% { opacity: 0.35; }
45% { opacity: 1; transform: none; }
58% { opacity: 0.7; }
70% { opacity: 1; }
100% { opacity: 1; transform: none; }
}
@keyframes term-enter-boot-scan {
0% { opacity: 0; background-position: 0 -150%; }
12% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; background-position: 0 150%; }
}
/* Wipe, reveals top-to-bottom behind a bright edge. */
html[data-term-anim="wipe"] .terminal-container.term-enter {
animation-name: term-enter-wipe;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
}
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
animation-name: term-enter-wipe-edge;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
}
@keyframes term-enter-wipe {
0% { opacity: 1; clip-path: inset(0 0 100% 0); }
100% { opacity: 1; clip-path: inset(0 0 0 0); }
}
@keyframes term-enter-wipe-edge {
0% { opacity: 0.9; clip-path: inset(0 0 100% 0); }
80% { opacity: 0.45; }
100% { opacity: 0; clip-path: inset(0 0 0 0); }
}
/* Slide up, rises into place from below. */
html[data-term-anim="slide"] .terminal-container.term-enter {
animation-name: term-enter-slide;
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes term-enter-slide {
0% { opacity: 0; transform: translate3d(0, 34px, 0); }
100% { opacity: 1; transform: none; }
}
/* Fade, quiet, with a touch of scale. */
html[data-term-anim="fade"] .terminal-container.term-enter {
animation-name: term-enter-fade;
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes term-enter-fade {
0% { opacity: 0; transform: scale(0.985); }
100% { opacity: 1; transform: none; }
}
/* ── Connection-line entrance animations ───────────────────────────────────
`--line-len` is the measured path length, stamped inline by
_applyLineEntrances(); `--line-enter-delay` is negative when an entrance is
resuming after the SVG was rebuilt underneath it. */
.connection-line.line-enter {
animation-delay: var(--line-enter-delay, 0ms);
animation-fill-mode: both;
}
/* Draw, the line paints itself from the tab down to the window. Overrides the
resting 5 3 dash to a single full-length dash for the duration. */
html[data-line-anim="draw"] .connection-line.line-enter {
stroke-dasharray: var(--line-len);
animation-name: line-enter-draw;
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.8, 0.3, 1);
}
@keyframes line-enter-draw {
from { stroke-dashoffset: var(--line-len); opacity: 1; }
to { stroke-dashoffset: 0; opacity: 0.9; }
}
/* Fade, plain opacity ramp, dashes intact. */
html[data-line-anim="fade"] .connection-line.line-enter {
animation-name: line-enter-fade;
animation-duration: calc(300ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes line-enter-fade {
from { opacity: 0; }
to { opacity: 0.9; }
}
/* Packet, the base line fades in and a separate bright dash rides down it. The
packet is its own cloned path so the base line keeps its dashed look. */
html[data-line-anim="packet"] .connection-line.line-enter {
animation-name: line-enter-fade;
animation-duration: calc(300ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
.connection-line-packet {
fill: none;
stroke: #cdfbe3;
stroke-width: 4;
stroke-linecap: round;
pointer-events: none;
stroke-dasharray: 20px 100000px;
filter: drop-shadow(0 0 4px rgba(0, 255, 102, 0.95)) drop-shadow(0 0 10px rgba(59, 130, 246, 0.7));
animation-name: line-enter-packet;
animation-duration: calc(700ms * var(--anim-enter-scale, 1));
animation-delay: var(--line-enter-delay, 0ms);
animation-timing-function: cubic-bezier(0.4, 0, 0.3, 1);
animation-fill-mode: both;
}
@keyframes line-enter-packet {
0% { stroke-dashoffset: 20px; opacity: 0; }
12% { opacity: 1; }
85% { opacity: 1; }
100% { stroke-dashoffset: calc(-1 * var(--line-len)); opacity: 0; }
}
.anim-lab-check {
display: flex;
align-items: center;
gap: 6px;
margin: -4px 0 12px;
padding: 0 2px;
color: var(--text-dim);
font-size: 0.66rem;
cursor: pointer;
}
.anim-lab-check input { accent-color: #00ff66; }
@media (prefers-reduced-motion: reduce) {
.session-tab.tab-enter,
.session-tab.tab-enter::before,
.subagent-window.win-enter,
.subagent-window.win-enter::before,
.ultracode-window.win-enter,
.ultracode-window.win-enter::before,
.terminal-container.term-enter,
.terminal-container.term-enter::before,
.connection-line.line-enter,
.connection-line-packet {
animation: none !important;
}
}
.session-tab .tab-status {
width: 6px;
height: 6px;
@@ -1447,6 +2116,11 @@ body.solo-mode .btn-lifecycle-log {
color: #8ab4f8;
}
.session-tab .tab-mode.antigravity {
background: rgba(34, 211, 238, 0.2);
color: #22d3ee;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -3531,6 +4205,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #eff6ff;
}
/* Antigravity mode colors */
.btn-toolbar.btn-run.mode-antigravity,
.btn-toolbar.btn-run-gear.mode-antigravity {
background: linear-gradient(135deg, #0b2b33 0%, #0e7490 55%, #0891b2 100%);
border-color: rgba(34, 211, 238, 0.5);
color: #cffafe;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-antigravity:hover,
.btn-toolbar.btn-run-gear.mode-antigravity:hover {
background: linear-gradient(135deg, #124450 0%, #0891b2 55%, #06b6d4 100%);
box-shadow: 0 0 12px rgba(34, 211, 238, 0.35), 0 2px 8px rgba(8, 145, 178, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(103, 232, 249, 0.6);
color: #ecfeff;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -3546,7 +4236,12 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
padding: 4px;
z-index: 1000;
min-width: 190px;
max-width: 300px;
/* Every long row inside (recent-session paths, saved URL names) ellipsizes, so
the cap is a deliberate choice rather than a fit-the-content result: the menu
overlays the terminal and does not need to reach across it. 250 rather than a
rounder 240 because it is the width at which the common `~/<dir>/<repo>` +
timestamp recent-session row still fits whole, which is what that list is for. */
max-width: 250px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
}
.run-mode-menu.active {
@@ -3585,6 +4280,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.opencode { background: #10b981; }
.run-mode-dot.codex { background: #a855f7; }
.run-mode-dot.gemini { background: #8ab4f8; }
.run-mode-dot.antigravity { background: #22d3ee; }
.run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width;
@@ -3611,8 +4307,19 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
max-height: 200px;
overflow-y: auto;
}
/* `overflow-y: auto` computes overflow-x to auto as well, which makes this a scroll
container and lets a row size itself to its own content. A long path therefore
scrolled sideways instead of ellipsizing, and before the menu was narrowed it
simply pinned the menu at its max-width. */
.run-mode-history .run-mode-option {
max-width: 100%;
}
.run-mode-option .hist-dir {
flex: 1;
/* A flex item's default min-width is auto, so without this the path refuses to
shrink below its own text and pushes the date out of the menu instead of
ellipsizing. Only visible once the menu is narrow enough to force the choice. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -10232,9 +10939,10 @@ kbd {
}
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
default via the marker class below; the server strips it at render when the
setting is on and the client toggles it live on save. */
live 5-hour + weekly plan limits parsed from the Claude statusline. Ships
hidden via the marker class below because display is PER-DEVICE and the
server cannot read localStorage; settings-ui.js reveals it on load (desktop
default ON, handhelds OFF) and on a live toggle. */
.header-plan-usage {
display: inline-flex !important;
align-items: center;
@@ -12561,6 +13269,53 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.run-mode-dot.web { background: #38bdf8; }
.run-mode-webviews { max-height: 180px; overflow-y: auto; }
/* A saved URL is a ROW: open on the left, edit + delete on the right, so a URL can
be changed or removed without first opening it as a tab. The side buttons stay
permanently visible rather than hover-revealed, because this menu is used on
touch devices where there is no hover to reveal them with. */
.run-mode-row--web {
display: flex;
align-items: center;
gap: 2px;
}
.run-mode-row--web .run-mode-option--web {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
/* The label needs its own element: `text-overflow` on the button does nothing,
because the button is a flex container and a bare text node in one becomes an
anonymous flex item that ellipsis cannot reach. Without this a long dashboard
name widens the whole menu instead of truncating. */
.run-mode-web-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-mode-row-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: none;
border: none;
border-radius: var(--btn-radius);
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.run-mode-row-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--text);
}
.run-mode-webview-delete { font-size: 1.05rem; }
.run-mode-webview-delete:hover { color: var(--danger, #ef4444); }
.run-mode-empty {
padding: 4px 10px 6px;
font-size: 0.75em;
+27 -3
View File
@@ -465,6 +465,10 @@ Object.assign(CodemanApp.prototype, {
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
this._appendUltracodeAgentConnectionLines(svg, rects);
}
// Every path above was just created from scratch, so any line entrance in
// flight has to be re-attached here (resumed via a negative animation-delay).
this._applyLineEntrances?.(svg);
},
// ═══════════════════════════════════════════════════════════════
@@ -729,12 +733,17 @@ Object.assign(CodemanApp.prototype, {
</div>
`;
// Only the `fly` entrance style parks the window on its parent tab; every
// CSS-animated style (entrance-animations.js) needs it to start at its
// resting position, or the animation would play at the wrong place.
const flyFromTab = !!parentTab && !isMobile && this.windowEntranceFliesFromTab?.() !== false;
// If we have a parent tab, start window at tab position for spawn animation
if (isMobile) {
// Mobile: position using top (keyboard-aware positioning calculated above)
win.style.top = `${finalY}px`;
win.style.bottom = 'auto';
} else if (parentTab) {
} else if (flyFromTab) {
const tabRect = parentTab.getBoundingClientRect();
win.style.left = `${tabRect.left}px`;
win.style.top = `${tabRect.bottom}px`;
@@ -812,7 +821,7 @@ Object.assign(CodemanApp.prototype, {
this.subagentWindows.get(agentId).resizeObserver = resizeObserver;
// Animate to final position if spawning from tab (desktop only)
if (parentTab && !isMobile) {
if (flyFromTab) {
requestAnimationFrame(() => {
win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
win.style.left = `${finalX}px`;
@@ -824,11 +833,26 @@ Object.assign(CodemanApp.prototype, {
setTimeout(() => {
win.style.transition = '';
win.classList.remove('spawning');
// The line only becomes meaningful once the window has landed, so its
// draw-in starts here rather than at spawn.
this.markConnectionLineEntering?.(agentId);
this.updateConnectionLines();
}, 400);
});
} else {
// No animation (mobile uses CSS positioning), just update connection lines
// CSS-animated entrance styles (and mobile) run in place. The window is
// already at its resting position, so the connection line can be drawn
// against a correct rect right away and animate alongside it.
//
// Skipped when the window spawns hidden (its agent belongs to a background
// tab): a display:none element never runs its animation, so `animationend`
// would never fire and the entrance class plus its inline custom property
// would stick to the window forever. Nothing is visible to animate anyway,
// and revealing it later is a tab switch, not a spawn.
if (!shouldHide) {
this.applyWindowEntrance?.(win);
this.markConnectionLineEntering?.(agentId);
}
this.updateConnectionLines();
}
+14
View File
@@ -1178,6 +1178,19 @@ Object.assign(CodemanApp.prototype, {
},
showWelcome() {
// Phones get the session overview instead of the welcome screen: on a small
// screen "which session is blocked on me" beats "how do I start one". The
// gate lives in mobile-overview.js; every other device falls through
// unchanged. Both surfaces are toggled here so a breakpoint change (rotate,
// unfold) swaps cleanly instead of showing both.
if (this.shouldUseMobileOverview?.()) {
const overlay = document.getElementById('welcomeOverlay');
if (overlay) overlay.classList.remove('visible');
this.showMobileOverview();
this._updateCjkInputState?.();
return;
}
this.hideMobileOverview?.();
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.add('visible');
@@ -1191,6 +1204,7 @@ Object.assign(CodemanApp.prototype, {
},
hideWelcome() {
this.hideMobileOverview?.();
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.remove('visible');
+4
View File
@@ -210,6 +210,9 @@ Object.assign(CodemanApp.prototype, {
document.body.appendChild(win);
// Drop the spawn class on the next frame so the transition runs.
requestAnimationFrame(() => win.classList.remove('spawning'));
// Then hand over to the chosen entrance style (no-op for `fly`/`off`, which
// leave the small scale-in transition above as the whole animation).
this.applyWindowEntrance?.(win);
const header = win.querySelector('.ultracode-window-header');
const dragListeners = this.makeWindowDraggable(win, header);
@@ -586,6 +589,7 @@ Object.assign(CodemanApp.prototype, {
document.body.appendChild(win);
requestAnimationFrame(() => win.classList.remove('spawning'));
this.applyWindowEntrance?.(win);
const header = win.querySelector('.ultracode-window-header');
const dragListeners = this.makeWindowDraggable(win, header);
+43 -13
View File
@@ -290,7 +290,13 @@ Object.assign(CodemanApp.prototype, {
// ── Run-menu entries ──────────────────────────────────────────────────────
/** Saved dashboards listed inside the Run dropdown, under "Web / URL". */
/**
* Saved dashboards listed inside the Run dropdown, under "Web / URL".
*
* Each row carries its own edit and delete buttons. Without them the only way to
* change or remove a saved URL was to open it as a tab first and go through the
* tab's gear, which is a dead end for a URL you no longer want open at all.
*/
renderWebviewMenuItems() {
const container = document.getElementById('runModeWebviews');
if (!container) return;
@@ -300,15 +306,20 @@ Object.assign(CodemanApp.prototype, {
return;
}
container.innerHTML = list
.map(
(w) => `<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${escapeHtml(
JSON.stringify(w.id)
)})" title="${escapeHtml(w.url)}">
<span class="run-mode-menu-icon">${w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>'}</span>${escapeHtml(
w.name
)}
</button>`
)
.map((w) => {
const jsonId = escapeHtml(JSON.stringify(w.id));
const name = escapeHtml(w.name);
const icon = w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>';
return `<div class="run-mode-row run-mode-row--web">
<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${jsonId})" title="${escapeHtml(w.url)}">
<span class="run-mode-menu-icon">${icon}</span><span class="run-mode-web-name">${name}</span>
</button>
<button class="run-mode-row-btn run-mode-webview-edit" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})"
title="Edit URL" aria-label="Edit ${name}">&#x2699;</button>
<button class="run-mode-row-btn run-mode-webview-delete" onclick="event.stopPropagation(); app.deleteWebviewById(${jsonId})"
title="Delete URL" aria-label="Delete ${name}">&times;</button>
</div>`;
})
.join('');
},
@@ -429,17 +440,36 @@ Object.assign(CodemanApp.prototype, {
async deleteWebview() {
const id = this._editingWebviewId;
if (!id) return;
if (await this._confirmAndDeleteWebview(id)) this.closeWebviewModal();
},
/**
* Delete straight from a Run-dropdown row, without opening the editor first.
*
* The dropdown's outside-click handler closes the menu when the click target is
* not inside it, and by the time the delete resolves this row is gone, so the
* menu is re-asserted open: deleting one of several saved URLs should leave you
* looking at the rest of the list.
*/
async deleteWebviewById(id) {
if (!id) return;
if (!(await this._confirmAndDeleteWebview(id))) return;
document.getElementById('runModeMenu')?.classList.add('active');
},
/** Shared by the row button and the editor modal. @returns true when deleted. */
async _confirmAndDeleteWebview(id) {
const webview = this.webviews.get(id);
if (!confirm(`Delete "${webview?.name || id}"?`)) return;
if (!confirm(`Delete "${webview?.name || id}"?`)) return false;
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
if (!res || !res.ok) {
this.showToast?.('Could not delete URL', 'error');
return;
return false;
}
this._removeWebviewTab(id);
this.webviews.delete(id);
this.closeWebviewModal();
this.renderWebviewMenuItems();
this.renderSessionTabs();
return true;
},
});
+87 -37
View File
@@ -19,6 +19,7 @@ import {
type SessionColor,
type CodexConfig,
type GeminiConfig,
type AntigravityConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
@@ -65,7 +66,7 @@ import {
updateCaseModel,
stripCaseEnvKeys,
applyStatusLineConfig,
refreshStaleHookSecret,
refreshStaleCodemanHooks,
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
@@ -282,25 +283,35 @@ export function _resetPasteRateBuckets(): void {
/**
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`
* and Gemini `--approval-mode yolo` disable the safety classifier the non-granted-user
* downgrade is meant to keep on, so clamp them for a non-granted owner. buildGeminiCommand
* defaults an ABSENT approvalMode to yolo, so the gemini config must be MATERIALIZED
* (auto_edit) even when the request sent none. No-op in single-user mode / for a granted
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`,
* Gemini `--approval-mode yolo`, and Antigravity `--dangerously-skip-permissions` disable
* the safety classifier the non-granted-user downgrade is meant to keep on, so clamp them
* for a non-granted owner. buildGeminiCommand defaults an ABSENT approvalMode to yolo, so
* the gemini config must be MATERIALIZED (auto_edit) even when the request sent none.
* Antigravity is like Codex: an ABSENT config already defaults safe (no bypass flag), so
* only a sent config needs the flag forced off. No-op in single-user mode / for a granted
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
codexConfig: CodexConfig | undefined,
geminiConfig: GeminiConfig | undefined
): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined }> {
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig };
// Non-granted: force codex bypass off (only meaningful when a config was sent) and
// materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
if (granted) return { codexConfig, geminiConfig, antigravityConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
return { codexConfig: clampedCodex, geminiConfig: clampedGemini };
const clampedAntigravity = antigravityConfig
? { ...antigravityConfig, dangerouslySkipPermissions: false }
: antigravityConfig;
return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity };
}
export function registerSessionRoutes(
@@ -414,6 +425,7 @@ export function registerSessionRoutes(
body.mode !== 'opencode' &&
body.mode !== 'codex' &&
body.mode !== 'gemini' &&
body.mode !== 'antigravity' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -445,7 +457,7 @@ export function registerSessionRoutes(
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
if ((body.mode ?? 'claude') === 'claude') {
await refreshStaleHookSecret(workingDir).catch(() => {});
await refreshStaleCodemanHooks(workingDir).catch(() => {});
}
// Check OpenCode availability if requested
@@ -480,6 +492,15 @@ export function registerSessionRoutes(
);
}
}
if (body.mode === 'antigravity') {
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
}
}
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
@@ -521,18 +542,20 @@ export function registerSessionRoutes(
? body.codexConfig?.model
: mode === 'gemini'
? body.geminiConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
: mode === 'antigravity'
? body.antigravityConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig } = await clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig
);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
const {
codexConfig: gatedCodexConfig,
geminiConfig: gatedGeminiConfig,
antigravityConfig: gatedAntigravityConfig,
} = await clampExternalCliBypassForOwner(owner, body.codexConfig, body.geminiConfig, body.antigravityConfig);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir,
@@ -547,6 +570,7 @@ export function registerSessionRoutes(
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
@@ -760,11 +784,12 @@ export function registerSessionRoutes(
try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
// Ralph tracker is not supported for opencode / codex / gemini sessions
// Ralph tracker is not supported for opencode / codex / gemini / antigravity sessions
if (
session.mode !== 'opencode' &&
session.mode !== 'codex' &&
session.mode !== 'gemini' &&
session.mode !== 'antigravity' &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -1943,6 +1968,7 @@ export function registerSessionRoutes(
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
envOverrides,
effort,
} = parseBody(QuickStartSchema, req.body);
@@ -1988,6 +2014,7 @@ export function registerSessionRoutes(
modelOverride !== undefined ||
codexConfig ||
geminiConfig ||
antigravityConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2018,6 +2045,7 @@ export function registerSessionRoutes(
effort ||
codexConfig ||
geminiConfig ||
antigravityConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2110,6 +2138,17 @@ export function registerSessionRoutes(
}
}
// Check Antigravity availability if requested
if (mode === 'antigravity') {
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
}
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -2156,8 +2195,8 @@ export function registerSessionRoutes(
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
// (Claude-specific — OpenCode, Codex, Gemini, and Antigravity use their own systems)
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity') {
await writeHooksConfig(resolvedCasePath);
}
@@ -2170,14 +2209,21 @@ export function registerSessionRoutes(
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
}
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
// configured project. Skipped for external CLIs (they use their own systems).
if (docker && docker.hooksEnabled && mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
if (
docker &&
docker.hooksEnabled &&
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity'
) {
try {
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
const templatePath = await ctx.getDefaultClaudeMdPath();
@@ -2186,7 +2232,7 @@ export function registerSessionRoutes(
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
await writeHooksConfig(resolvedCasePath);
} else {
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
}
} catch {
/* non-fatal — the session still runs, hooks may be degraded */
@@ -2206,6 +2252,7 @@ export function registerSessionRoutes(
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
@@ -2224,17 +2271,19 @@ export function registerSessionRoutes(
? codexConfig?.model
: mode === 'gemini'
? geminiConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
: mode === 'antigravity'
? antigravityConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(
owner,
codexConfig,
geminiConfig
);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
const {
codexConfig: qsGatedCodexConfig,
geminiConfig: qsGatedGeminiConfig,
antigravityConfig: qsGatedAntigravityConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: resolvedCasePath,
@@ -2250,6 +2299,7 @@ export function registerSessionRoutes(
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
envOverrides,
effort,
remote,
+22 -5
View File
@@ -374,7 +374,7 @@ export function registerSystemRoutes(
});
// ═══════════════════════════════════════════════════════════════
// CLI Integrations (OpenCode, Codex, Gemini)
// CLI Integrations (OpenCode, Codex, Gemini, Antigravity)
// ═══════════════════════════════════════════════════════════════
// ========== OpenCode ==========
@@ -405,6 +405,16 @@ export function registerSystemRoutes(
};
});
// ========== Antigravity ==========
app.get('/api/antigravity/status', async () => {
const { isAntigravityAvailable, resolveAntigravityDir } = await import('../../utils/antigravity-cli-resolver.js');
return {
available: isAntigravityAvailable(),
path: resolveAntigravityDir(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
@@ -692,20 +702,27 @@ export function registerSystemRoutes(
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
}
// Service toggles resolve from `merged` (existing + incoming), NEVER from the
// raw request body. A PARTIAL PUT omits keys it does not intend to change, and
// reading the body directly turned every omission into "apply the default":
// a body of just `{statusLineTelemetry:true}` would START the subagent watcher
// (`?? true`) and STOP the workflow + image watchers (`?? false`), silently
// undoing the user's persisted config. Reading `merged` makes any PUT reconcile
// services to the effective stored settings instead, which also self-heals
// drift. Same convention as the tmuxHistoryLimit block above.
// Handle subagent tracking toggle dynamically
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
// Either the docked panel OR the floating windows keep the watcher running.
toggleService(
((settings.showUltracodeAgents as boolean) ?? false) ||
((settings.ultracodeFloatingWindows as boolean) ?? false),
((merged.showUltracodeAgents as boolean) ?? false) || ((merged.ultracodeFloatingWindows as boolean) ?? false),
workflowRunWatcher,
'Workflow run watcher'
);
// Handle image watcher toggle dynamically
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled
for (const session of ctx.sessions.values()) {
if (session.imageWatcherEnabled) {
+25 -5
View File
@@ -86,7 +86,7 @@ export const FilesystemPreviewQuerySchema = z.object({
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_'];
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_'];
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
@@ -116,7 +116,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, and GOOGLE_* keys are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, and ANTIGRAVITY_* keys are allowed.',
}
);
@@ -206,9 +206,26 @@ const GeminiConfigSchema = z
})
.optional();
/** Schema for Antigravity CLI (agy)-specific configuration */
const AntigravityConfigSchema = z
.object({
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
dangerouslySkipPermissions: z.boolean().optional(),
resumeConversationId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/)
.optional(),
})
.optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
@@ -220,6 +237,7 @@ export const CreateSessionSchema = z.object({
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -328,6 +346,7 @@ const RemoteCommandOverridesSchema = z
opencode: z.string().min(1).max(300).optional(),
codex: z.string().min(1).max(300).optional(),
gemini: z.string().min(1).max(300).optional(),
antigravity: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -600,10 +619,11 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -957,7 +977,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
+13 -5
View File
@@ -855,14 +855,21 @@ export class WebServer extends EventEmitter {
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
this.app.setNotFoundHandler(async (req, reply) => {
const notFound = `Route ${req.method}:${req.url} not found`;
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
// `import('/chunk.js')`, `url(/img.png)` inside a stylesheet) lands here,
// because `<base href>` cannot rewrite a URL built at runtime. Its Referer says
// which dashboard to relay to. Deliberately placed on the 404 path so every
// real Codeman route still wins.
//
// Tried BEFORE the API-shaped 404, because a dashboard's own assets commonly
// live under its `/api/...` namespace and were the one class this could never
// rescue. Reaching this handler at all already proves no Codeman route matched,
// and the relay declines unless the Referer carries a live capability, so
// genuinely unknown `/api` paths still get the envelope below.
if (await tryWebviewRefererFallback(req, reply)) return reply;
if (req.url.startsWith('/api')) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
}
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
// `import('/chunk.js')`) lands here, because `<base href>` cannot rewrite a URL
// built at runtime. Its Referer says which dashboard to relay to. Deliberately
// placed on the 404 path so every real Codeman route still wins.
if (await tryWebviewRefererFallback(req, reply)) return reply;
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
});
@@ -2454,6 +2461,7 @@ export class WebServer extends EventEmitter {
openCodeConfig: muxSession.mode === 'opencode' ? savedState?.openCodeConfig : undefined,
codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined,
geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
+154 -6
View File
@@ -366,15 +366,32 @@ export function buildDownstreamResponseHeaders(
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
* their own backend, and it presents as the dashboard's own "Failed to fetch".
*
* The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
* whole class instead of trading security for it: the page never emits a
* root-absolute request in the first place.
* The `Referer`-keyed 404 fallback catches some of these, but it is a rescue rather
* than a fix (it only fires for a request that already missed every Codeman route,
* and only when the browser sends a usable `Referer`). Rewriting inside the iframe
* removes the whole class instead: the page never emits a root-absolute request in
* the first place.
*
* ## Why the DOM sinks are patched too, not just fetch/XHR
*
* A dashboard that renders `container.innerHTML = '<img src="/api/hero?slug=x">'`
* or `img.src = '/api/slide?n=01'` produces exactly the same root-absolute request,
* and NONE of the other layers can reach it: `<base>` does not apply to
* root-absolute URLs at all, and `rewriteHtml()` only ever sees the initial
* document, not markup built later by page script. The visible symptom is very
* specific and easy to misread: the dashboard's DATA loads (reads go through
* `fetch`, which was already patched) while every IMAGE stays broken. So the same
* `rw()` is applied to `innerHTML`/`outerHTML`/`insertAdjacentHTML`, to
* `setAttribute`, and to the `src`/`href`/`srcset`/... property setters, with a
* `MutationObserver` as a last net for any sink not patched above (that one costs a
* wasted 404 per node, since the browser starts fetching on insert, so it is a net
* and not the mechanism).
*
* Runs before any page script because it is injected immediately after `<base>`.
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
* (already handled by `<base>`) and cross-origin URLs are passed through.
* (already handled by `<base>`) and cross-origin URLs are passed through. Every
* rewrite is idempotent, so a value that passes through two layers is unchanged by
* the second.
*/
export function runtimeUrlShim(prefix: string): string {
// Kept dependency-free and defensive: it runs inside a page we do not control,
@@ -420,6 +437,137 @@ if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
window[k]=W;
});
var A=['src','href','action','poster','data','formaction','srcset'];
function rwSet(v){
try{
return String(v).split(',').map(function(p){
var t=p.trim();if(!t)return t;
var i=t.search(/\\s/);
return i===-1?rw(t):rw(t.slice(0,i))+t.slice(i);
}).join(', ');
}catch(e){return v;}
}
function rwAttr(n,v){
try{
if(v==null)return v;
var k=String(n).toLowerCase();
if(k==='srcset')return rwSet(v);
return A.indexOf(k)===-1?v:rw(v);
}catch(e){return v;}
}
// CSS built at runtime is the one sink NO relay can rescue: a <style> element has
// no URL of its own, so an opaque-origin document sends an EMPTY Referer with the
// resulting image request, and the 404 fallback has nothing to key on.
function rwCss(s){
try{
return String(s).replace(/url\\(\\s*(['"]?)(\\/(?!\\/)[^'")]*)\\1\\s*\\)/gi,function(m,q,u){return 'url('+q+rw(u)+q+')';});
}catch(e){return s;}
}
// Each value goes through rw() rather than a blind prefix concat, because unlike
// the server-side rewriteHtml() this runs on markup that may ALREADY be proxied
// (a page re-injecting its own outerHTML), and rw() is the idempotent one.
function rwHtml(s){
try{
if(typeof s!=='string')return s;
return s
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rw(v)+q;})
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rw(v)+q;})
.replace(/(\\ssrcset\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rwSet(v)+q;})
.replace(/(\\ssrcset\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rwSet(v)+q;})
.replace(/(<style\\b[^>]*>)([^]*?)(<\\/style>)/gi,function(m,a,b,c){return a+rwCss(b)+c;});
}catch(e){return s;}
}
// Marked with __cmrw so a double injection (a page that re-runs the shim) cannot
// wrap an already-wrapped setter and rewrite twice.
function patchProp(C,prop,conv){
try{
if(!C||!C.prototype)return;
var d=Object.getOwnPropertyDescriptor(C.prototype,prop);
if(!d||!d.set||d.set.__cmrw)return;
var s=d.set;
var ns=function(v){var w=v;try{w=conv(v);}catch(e){}return s.call(this,w);};
ns.__cmrw=1;
Object.defineProperty(C.prototype,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
}catch(e){}
}
function patchHtmlProp(O,prop){
try{
if(!O)return;
var d=Object.getOwnPropertyDescriptor(O,prop);
if(!d||!d.set||d.set.__cmrw)return;
var s=d.set;
var ns=function(v){return s.call(this,rwHtml(v));};
ns.__cmrw=1;
Object.defineProperty(O,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
}catch(e){}
}
function patchFn(O,name,wrap){
try{
var f=O&&O[name];
if(typeof f!=='function'||f.__cmrw)return;
var nf=wrap(f);nf.__cmrw=1;O[name]=nf;
}catch(e){}
}
[['HTMLImageElement','src'],['HTMLImageElement','srcset'],['HTMLSourceElement','src'],
['HTMLSourceElement','srcset'],['HTMLMediaElement','src'],['HTMLVideoElement','poster'],
['HTMLScriptElement','src'],['HTMLIFrameElement','src'],['HTMLEmbedElement','src'],
['HTMLTrackElement','src'],['HTMLLinkElement','href'],['HTMLAnchorElement','href'],
['HTMLAreaElement','href'],['HTMLObjectElement','data'],['HTMLFormElement','action']
].forEach(function(p){patchProp(window[p[0]],p[1],p[1]==='srcset'?rwSet:rw);});
var EP=window.Element&&window.Element.prototype;
patchHtmlProp(EP,'innerHTML');
patchHtmlProp(EP,'outerHTML');
patchHtmlProp(window.ShadowRoot&&window.ShadowRoot.prototype,'innerHTML');
patchFn(EP,'insertAdjacentHTML',function(f){return function(p,h){return f.call(this,p,rwHtml(h));};});
patchFn(EP,'setAttribute',function(f){return function(n,v){return f.call(this,n,rwAttr(n,v));};});
patchFn(EP,'setAttributeNS',function(f){return function(ns,n,v){
var k=String(n==null?'':n),i=k.indexOf(':');
return f.call(this,ns,n,rwAttr(i===-1?k:k.slice(i+1),v));
};});
// Last net: anything inserted by a sink not patched above still gets corrected.
// setAttribute below is the patched one, so this stays idempotent and terminates.
try{
var doc=window.document,MO=window.MutationObserver;
if(MO&&doc&&doc.documentElement){
var fix=function(el){
try{
if(!el||el.nodeType!==1||!el.hasAttribute)return;
for(var i=0;i<A.length;i++){
var n=A[i];if(!el.hasAttribute(n))continue;
var c=el.getAttribute(n),x=rwAttr(n,c);
if(x!=null&&x!==c)el.setAttribute(n,x);
}
}catch(e){}
};
var fixStyle=function(el){
try{
if(!el||el.tagName!=='STYLE')return;
var t=el.textContent;
if(!t||t.indexOf('url(')===-1)return;
var n=rwCss(t);
if(n!==t)el.textContent=n;
}catch(e){}
};
var scan=function(node){
try{
fix(node);fixStyle(node);
if(node&&node.querySelectorAll){
var l=node.querySelectorAll('[src],[href],[action],[poster],[data],[srcset],[formaction]');
for(var i=0;i<l.length;i++)fix(l[i]);
var st=node.querySelectorAll('style');
for(var j=0;j<st.length;j++)fixStyle(st[j]);
}
}catch(e){}
};
new MO(function(ms){
for(var i=0;i<ms.length;i++){
var m=ms[i];
if(m.type==='attributes')fix(m.target);
else for(var j=0;j<m.addedNodes.length;j++)scan(m.addedNodes[j]);
}
}).observe(doc.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:A});
}
}catch(e){}
}catch(e){}})();</script>`;
}