agent: add ?hide= URL param to suppress header/composer chrome

For embedding companions (e.g. trollshell WebKitGTK) that supply their
own header/input chrome around the per-agent page and only want the
raw terminal feed. Comma-separated so both axes can hide in one param:
?hide=header,input. Zeroes --agent-header-h/--agent-composer-h via body
classes rather than conditionally computing padding, so every dependent
calc() (overlay offset, terminal scroll padding) collapses in one place.
This commit is contained in:
iris 2026-09-11 16:57:15 +02:00 committed by mara
commit ec51ec924a
2 changed files with 97 additions and 61 deletions

View file

@ -75,6 +75,14 @@ function tokenTotal(u: TokenUsage | null): number | null {
); );
} }
// `?hide=header,input` — an embedding companion window (e.g. a native
// desktop shell) supplies its own header/composer chrome around this
// page, so it asks for just the terminal feed. Read once per load (the
// value can't change without a navigation); comma-separated so both
// axes can be hidden in one param.
const hideParam = new URLSearchParams(window.location.search).get("hide");
const HIDDEN = new Set(hideParam ? hideParam.split(",").filter(Boolean) : []);
export function Root() { export function Root() {
useApplyThemeOverride(THEME_KEY); useApplyThemeOverride(THEME_KEY);
useApplyMotionOverride(MOTION_KEY); useApplyMotionOverride(MOTION_KEY);
@ -82,6 +90,17 @@ export function Root() {
const { todos, refresh: refreshTodos } = useTodos(); const { todos, refresh: refreshTodos } = useTodos();
const [openPanel, setOpenPanel] = useState<OpenPanel>(null); const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const liveStreamRef = useRef<LiveStreamHandle>(null); const liveStreamRef = useRef<LiveStreamHandle>(null);
const hideHeader = HIDDEN.has("header");
const hideInput = HIDDEN.has("input");
// Body classes, not inline styles: agent.css's `--agent-header-h`/
// `--agent-header-real-h`/`--agent-composer-h` custom properties already
// drive every dependent calc() (overlay offset, terminal scroll padding)
// — zeroing them here collapses all of that in one place instead of
// touching each rule.
useEffect(() => {
document.body.classList.toggle("agent-hide-header", hideHeader);
document.body.classList.toggle("agent-hide-input", hideInput);
}, [hideHeader, hideInput]);
// Ticks the status badge's "thinking Xm Ys" age once a second, // Ticks the status badge's "thinking Xm Ys" age once a second,
// independent of the ~4s `/api/state` poll cadence — without its own // independent of the ~4s `/api/state` poll cadence — without its own
@ -111,7 +130,7 @@ export function Root() {
? `${state.label} // ${state.hive_name}` ? `${state.label} // ${state.hive_name}`
: `${tab} // hyperhive`; : `${tab} // hyperhive`;
}, [state?.label, state?.qualified_label, state?.hive_name]); }, [state?.label, state?.qualified_label, state?.hive_name]);
const termInput = ( const termInput = hideInput ? null : (
<footer class="agent-composer"> <footer class="agent-composer">
<TermInput <TermInput
label={state?.label ?? "…"} label={state?.label ?? "…"}
@ -191,22 +210,24 @@ export function Root() {
if (!state) { if (!state) {
return ( return (
<> <>
<Header label="…" pills={pills}> {hideHeader ? null : (
<StatusChips <Header label="…" pills={pills}>
statusLabel="… connecting" <StatusChips
statusTone="neutral" statusLabel="… connecting"
model="" statusTone="neutral"
availableModels={[]} model=""
onSelectModel={() => {}} availableModels={[]}
effort="" onSelectModel={() => {}}
availableEfforts={[]} effort=""
onSelectEffort={() => {}} availableEfforts={[]}
paused={false} onSelectEffort={() => {}}
onTogglePause={() => {}} paused={false}
thinking={false} onTogglePause={() => {}}
onCancelTurn={() => Promise.resolve()} thinking={false}
/> onCancelTurn={() => Promise.resolve()}
</Header> />
</Header>
)}
<main className="agent-main"> <main className="agent-main">
<LiveStream ref={liveStreamRef} /> <LiveStream ref={liveStreamRef} />
</main> </main>
@ -266,51 +287,53 @@ export function Root() {
return ( return (
<> <>
<Header {hideHeader ? null : (
label={state.label} <Header
hiveLabel={ label={state.label}
[state.swarm_name, state.hive_name].filter(Boolean).join(" / ") || hiveLabel={
null [state.swarm_name, state.hive_name].filter(Boolean).join(" / ") ||
} null
pills={pills}
>
<StatusChips
statusLabel={`${primaryStatus.glyph} ${primaryStatus.text}`}
statusTone={primaryStatus.tone}
statusTooltip={primaryStatus.tooltip}
model={state.model}
resolvedModel={state.resolved_model ?? undefined}
availableModels={state.available_models}
onSelectModel={(name) => {
postModel(name).then(() => refresh());
}}
effort={state.effort}
availableEfforts={state.available_efforts}
onSelectEffort={(level) => {
postEffort(level).then(() => refresh());
}}
ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined}
costLabel={cost !== null ? fmtTokens(cost) : undefined}
paused={state.paused}
onTogglePause={() => {
submitPauseResume(
resolveDashboardBase(state.dashboard_port),
state.label,
state.paused ? "resume" : "pause",
).then(() => refresh());
}}
thinking={effectiveTurnState === "thinking"}
onCancelTurn={() =>
postCancelTurn().then((r) => {
if (!r.ok)
liveStreamRef.current?.pushNote(
`✗ /cancel failed${r.detail ? ": " + r.detail : ""}`,
);
refresh();
})
} }
/> pills={pills}
</Header> >
<StatusChips
statusLabel={`${primaryStatus.glyph} ${primaryStatus.text}`}
statusTone={primaryStatus.tone}
statusTooltip={primaryStatus.tooltip}
model={state.model}
resolvedModel={state.resolved_model ?? undefined}
availableModels={state.available_models}
onSelectModel={(name) => {
postModel(name).then(() => refresh());
}}
effort={state.effort}
availableEfforts={state.available_efforts}
onSelectEffort={(level) => {
postEffort(level).then(() => refresh());
}}
ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined}
costLabel={cost !== null ? fmtTokens(cost) : undefined}
paused={state.paused}
onTogglePause={() => {
submitPauseResume(
resolveDashboardBase(state.dashboard_port),
state.label,
state.paused ? "resume" : "pause",
).then(() => refresh());
}}
thinking={effectiveTurnState === "thinking"}
onCancelTurn={() =>
postCancelTurn().then((r) => {
if (!r.ok)
liveStreamRef.current?.pushNote(
`✗ /cancel failed${r.detail ? ": " + r.detail : ""}`,
);
refresh();
})
}
/>
</Header>
)}
<main className="agent-main"> <main className="agent-main">
{state.status === "needs_login_idle" || {state.status === "needs_login_idle" ||
state.status === "needs_login_in_progress" ? ( state.status === "needs_login_in_progress" ? (

View file

@ -30,6 +30,19 @@
--agent-frost-blur: blur(12px) saturate(140%); --agent-frost-blur: blur(12px) saturate(140%);
} }
/* `?hide=header,input` (per-agent-page embed mode): `Root.tsx` toggles
these body classes when it omits `<Header>`/the composer footer.
Zeroing the custom properties here rather than overriding each
dependent rule collapses every calc() that reads them (overlay
offset, terminal scroll padding) in one place. */
body.agent-hide-header {
--agent-header-h: 0px;
--agent-header-real-h: 0px;
}
body.agent-hide-input {
--agent-composer-h: 0px;
}
html, html,
body { body {
height: 100%; height: 100%;