Skip to content

Commit 9c78489

Browse files
committed
feat(control-ui): full-screen terminal-only view mode for mobile WebViews
1 parent bafdec9 commit 9c78489

46 files changed

Lines changed: 316 additions & 129 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/web/control-ui.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ Use **Ctrl + backtick** to toggle the dock. The layout supports bottom and right
219219

220220
Sessions survive disconnects: a page reload, laptop sleep, or network blip detaches the session on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). `terminal.list` shows attachable sessions, `terminal.attach` adopts one (tmux-style take-over), and `terminal.text` reads a session's recent output as plain text without attaching — an agent/tooling affordance.
221221

222+
The terminal is also available as a full-screen, terminal-only document at `/?view=terminal`. The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal.
223+
222224
## Chat behavior
223225

224226
<AccordionGroup>

ui/src/app/app-host.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type CommandPaletteTargetDetail,
2020
} from "../components/command-palette.ts";
2121
import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts";
22+
import { t } from "../i18n/index.ts";
2223
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
2324
import { searchForSession } from "../lib/sessions/index.ts";
2425
import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts";
@@ -74,6 +75,15 @@ function resolveOnboardingMode(): boolean {
7475
return raw !== null && /^(?:1|true|yes|on)$/iu.test(raw.trim());
7576
}
7677

78+
/**
79+
* Terminal-only document mode (`?view=terminal`): the mobile apps embed the
80+
* terminal as a full-screen WebView page instead of the whole Control UI.
81+
* Fixed per document load — the apps construct the URL, users never toggle it.
82+
*/
83+
function isTerminalOnlyView(): boolean {
84+
return new URLSearchParams(globalThis.location?.search ?? "").get("view") === "terminal";
85+
}
86+
7787
function resolveTerminalThemeMode(): "dark" | "light" {
7888
return document.documentElement.dataset.themeMode === "light" ? "light" : "dark";
7989
}
@@ -102,13 +112,17 @@ export class OpenClawApp extends LitElement {
102112
@state() private loginShowGatewayPassword = false;
103113
@state() private pendingGatewayUrl: string | null = null;
104114
@state() private onboarding = resolveOnboardingMode();
115+
@state() private terminalAvailable = false;
116+
@state() private terminalClient: GatewayBrowserClient | null = null;
105117

118+
private readonly terminalOnly = isTerminalOnlyView();
106119
private runtime: ApplicationRuntime | undefined;
107120
private context: ApplicationContext<RouteId> | undefined;
108121
private readonly contextProvider = new ContextProvider(this, {
109122
context: applicationContext,
110123
});
111124
private stopGatewaySubscription: (() => void) | undefined;
125+
private stopConfigSubscription: (() => void) | undefined;
112126

113127
override createRenderRoot() {
114128
return this;
@@ -129,7 +143,16 @@ export class OpenClawApp extends LitElement {
129143
this.syncLoginConnection();
130144
}
131145
this.updateGatewayStatus(snapshot);
146+
this.updateTerminalSurface();
132147
});
148+
if (this.terminalOnly) {
149+
// Terminal availability also depends on config.terminalEnabled, which
150+
// can arrive after the gateway snapshot; track it for this document mode.
151+
this.updateTerminalSurface();
152+
this.stopConfigSubscription = this.context.config.subscribe(() => {
153+
this.updateTerminalSurface();
154+
});
155+
}
133156
void this.runtime.start().catch((error: unknown) => {
134157
console.error("[openclaw] application start failed", error);
135158
});
@@ -138,6 +161,8 @@ export class OpenClawApp extends LitElement {
138161
override disconnectedCallback() {
139162
this.stopGatewaySubscription?.();
140163
this.stopGatewaySubscription = undefined;
164+
this.stopConfigSubscription?.();
165+
this.stopConfigSubscription = undefined;
141166
this.runtime?.stop();
142167
this.runtime = undefined;
143168
this.context = undefined;
@@ -165,6 +190,18 @@ export class OpenClawApp extends LitElement {
165190
this.gatewayLastErrorCode = snapshot.lastErrorCode;
166191
};
167192

193+
private updateTerminalSurface() {
194+
if (!this.terminalOnly || !this.context) {
195+
return;
196+
}
197+
const snapshot = this.context.gateway.snapshot;
198+
this.terminalClient = snapshot.connected ? snapshot.client : null;
199+
this.terminalAvailable = isTerminalAvailable(
200+
snapshot,
201+
this.context.config.current.terminalEnabled ?? false,
202+
);
203+
}
204+
168205
override render() {
169206
const context = this.context;
170207
const runtime = this.runtime;
@@ -232,6 +269,21 @@ export class OpenClawApp extends LitElement {
232269
</openclaw-tooltip-provider>
233270
`;
234271
}
272+
// Terminal-only document (`?view=terminal`): the mobile apps embed this as
273+
// a full-screen WebView page, so render just the terminal — no shell chrome.
274+
if (this.terminalOnly) {
275+
return html`
276+
<openclaw-terminal-panel
277+
.client=${this.terminalClient}
278+
.available=${this.terminalAvailable}
279+
.themeMode=${resolveTerminalThemeMode()}
280+
fullscreen
281+
></openclaw-terminal-panel>
282+
${this.terminalAvailable
283+
? nothing
284+
: html`<div class="terminal-view-unavailable">${t("terminal.unavailable")}</div>`}
285+
`;
286+
}
235287
return html`
236288
<openclaw-tooltip-provider>
237289
${gatewayUrlConfirmation}

ui/src/components/terminal/terminal-panel.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,58 @@ describe("OpenClawTerminalPanel", () => {
8989
});
9090
});
9191
});
92+
93+
it("fullscreen mode auto-opens without dock chrome and survives last-tab close", async () => {
94+
createGhosttyTerminalMock.mockImplementation(async () => {
95+
return {
96+
terminal: {
97+
cols: 100,
98+
rows: 30,
99+
viewportY: 0,
100+
write: vi.fn(),
101+
focus: vi.fn(),
102+
},
103+
write: vi.fn(),
104+
fit: vi.fn(),
105+
dispose: vi.fn(),
106+
};
107+
});
108+
const requests: Array<{ method: string; params: unknown }> = [];
109+
const client: TerminalGatewayClient = {
110+
request: async <T>(method: string, params?: unknown) => {
111+
requests.push({ method, params });
112+
return {
113+
sessionId: "session-1",
114+
agentId: "ops",
115+
shell: "/bin/zsh",
116+
cwd: "/work/ops",
117+
confined: false,
118+
} as T;
119+
},
120+
addEventListener: () => () => {},
121+
};
122+
const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel;
123+
panel.client = client;
124+
panel.available = true;
125+
panel.fullscreen = true;
126+
document.body.append(panel);
127+
128+
// No toggle: the terminal-only document opens its session on mount.
129+
await vi.waitFor(() => {
130+
expect(requests.some((entry) => entry.method === "terminal.open")).toBe(true);
131+
});
132+
await panel.updateComplete;
133+
const section = panel.renderRoot.querySelector(".tp");
134+
expect(section?.classList.contains("tp--fullscreen")).toBe(true);
135+
expect(panel.renderRoot.querySelector(".tp-resizer")).toBeNull();
136+
expect(panel.renderRoot.querySelector(".tp-actions")).toBeNull();
137+
138+
// Closing the last tab must keep the panel (with its "+" button) rendered —
139+
// a fullscreen document has no toggle to bring a closed panel back.
140+
(panel.renderRoot.querySelector(".tp-tab__close") as HTMLElement).click();
141+
await panel.updateComplete;
142+
expect(requests.some((entry) => entry.method === "terminal.close")).toBe(true);
143+
expect(panel.renderRoot.querySelector(".tp")).not.toBeNull();
144+
expect(panel.renderRoot.querySelector(".tp-new")).not.toBeNull();
145+
});
92146
});

ui/src/components/terminal/terminal-panel.ts

Lines changed: 81 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ export class OpenClawTerminalPanel extends LitElement {
129129
@property({ type: Boolean }) available = false;
130130
/** Active Control UI color mode, mirrored into the terminal theme. */
131131
@property({ attribute: false }) themeMode: "dark" | "light" = "dark";
132+
/**
133+
* Terminal-only document mode (`?view=terminal`), used by the mobile apps'
134+
* WebViews: fills the viewport, always open while available, no dock chrome.
135+
*/
136+
@property({ type: Boolean }) fullscreen = false;
132137

133138
@state() private open = false;
134139
@state() private dock: TerminalDock = "bottom";
@@ -159,15 +164,21 @@ export class OpenClawTerminalPanel extends LitElement {
159164

160165
override connectedCallback(): void {
161166
super.connectedCallback();
162-
const layout = loadLayout();
163-
this.dock = layout.dock;
164-
this.height = layout.height;
165-
this.width = layout.width;
166-
// Only restore the open state when the surface is actually available.
167-
this.open = layout.open && this.available;
168-
window.addEventListener("keydown", this.onGlobalKeyDown);
169-
window.addEventListener(TOGGLE_EVENT, this.onToggleRequest);
170-
window.addEventListener("resize", this.onViewportResize);
167+
if (!this.fullscreen) {
168+
const layout = loadLayout();
169+
this.dock = layout.dock;
170+
this.height = layout.height;
171+
this.width = layout.width;
172+
// Only restore the open state when the surface is actually available.
173+
this.open = layout.open && this.available;
174+
window.addEventListener("keydown", this.onGlobalKeyDown);
175+
window.addEventListener(TOGGLE_EVENT, this.onToggleRequest);
176+
window.addEventListener("resize", this.onViewportResize);
177+
} else {
178+
// Fullscreen documents have no toggle/dock chrome; the panel is simply
179+
// open whenever the terminal surface is available.
180+
this.open = this.available;
181+
}
171182
if (this.open) {
172183
void this.restoreSessions();
173184
}
@@ -196,9 +207,10 @@ export class OpenClawTerminalPanel extends LitElement {
196207
// open preference, or the reconnect path would never auto-reopen.
197208
this.open = false;
198209
this.disposeAllTabs();
199-
} else if (!this.open && loadLayout().open) {
210+
} else if (!this.open && (this.fullscreen || loadLayout().open)) {
200211
// Hello arrived after mount (or a reconnect); restore the persisted
201-
// open state and reattach persisted sessions where possible.
212+
// open state (fullscreen documents are always open while available)
213+
// and reattach persisted sessions where possible.
202214
this.open = true;
203215
void this.restoreSessions();
204216
}
@@ -241,6 +253,10 @@ export class OpenClawTerminalPanel extends LitElement {
241253
* content simply shrinks to make room, so this reads as a real dock.
242254
*/
243255
private syncLayoutReservation(): void {
256+
if (this.fullscreen) {
257+
// No shell content to reserve space for in a terminal-only document.
258+
return;
259+
}
244260
const root = document.documentElement.style;
245261
const bottom =
246262
this.available && this.open && this.dock === "bottom" ? `${this.height}px` : "0px";
@@ -533,7 +549,10 @@ export class OpenClawTerminalPanel extends LitElement {
533549
this.activeId = this.tabs.at(-1)?.id ?? null;
534550
}
535551
this.persistLiveSessions();
536-
if (this.tabs.length === 0) {
552+
// Fullscreen documents (mobile WebViews) have no toggle to reopen a closed
553+
// panel, so closing the last tab keeps the panel with an empty tab strip
554+
// (the "+" button stays reachable) instead of leaving a dead blank page.
555+
if (this.tabs.length === 0 && !this.fullscreen) {
537556
this.closePanel();
538557
}
539558
}
@@ -650,15 +669,22 @@ export class OpenClawTerminalPanel extends LitElement {
650669
if (!this.available || !this.open) {
651670
return nothing;
652671
}
653-
const style = this.dock === "bottom" ? `height:${this.height}px` : `width:${this.width}px`;
672+
const mode = this.fullscreen ? "fullscreen" : this.dock;
673+
const style = this.fullscreen
674+
? nothing
675+
: this.dock === "bottom"
676+
? `height:${this.height}px`
677+
: `width:${this.width}px`;
654678
return html`
655-
<section class="tp tp--${this.dock}" style=${style} aria-label=${t("terminal.title")}>
656-
<div
657-
class="tp-resizer tp-resizer--${this.dock}"
658-
@pointerdown=${(e: PointerEvent) => this.startResize(e)}
659-
role="separator"
660-
aria-label=${t("terminal.resize")}
661-
></div>
679+
<section class="tp tp--${mode}" style=${style} aria-label=${t("terminal.title")}>
680+
${this.fullscreen
681+
? nothing
682+
: html`<div
683+
class="tp-resizer tp-resizer--${this.dock}"
684+
@pointerdown=${(e: PointerEvent) => this.startResize(e)}
685+
role="separator"
686+
aria-label=${t("terminal.resize")}
687+
></div>`}
662688
<header class="tp-header">
663689
<div class="tp-tabs" role="tablist">
664690
${this.tabs.map(
@@ -704,35 +730,37 @@ export class OpenClawTerminalPanel extends LitElement {
704730
${PLUS_GLYPH}
705731
</button>
706732
</div>
707-
<div class="tp-actions">
708-
<button
709-
class="tp-icon ${this.dock === "bottom" ? "is-active" : ""}"
710-
type="button"
711-
title=${t("terminal.dockBottom")}
712-
aria-label=${t("terminal.dockBottom")}
713-
@click=${() => this.setDock("bottom")}
714-
>
715-
${DOCK_BOTTOM_GLYPH}
716-
</button>
717-
<button
718-
class="tp-icon ${this.dock === "right" ? "is-active" : ""}"
719-
type="button"
720-
title=${t("terminal.dockRight")}
721-
aria-label=${t("terminal.dockRight")}
722-
@click=${() => this.setDock("right")}
723-
>
724-
${DOCK_RIGHT_GLYPH}
725-
</button>
726-
<button
727-
class="tp-icon"
728-
type="button"
729-
title=${t("terminal.hide")}
730-
aria-label=${t("terminal.hide")}
731-
@click=${() => this.closePanel()}
732-
>
733-
${CLOSE_GLYPH}
734-
</button>
735-
</div>
733+
${this.fullscreen
734+
? nothing
735+
: html`<div class="tp-actions">
736+
<button
737+
class="tp-icon ${this.dock === "bottom" ? "is-active" : ""}"
738+
type="button"
739+
title=${t("terminal.dockBottom")}
740+
aria-label=${t("terminal.dockBottom")}
741+
@click=${() => this.setDock("bottom")}
742+
>
743+
${DOCK_BOTTOM_GLYPH}
744+
</button>
745+
<button
746+
class="tp-icon ${this.dock === "right" ? "is-active" : ""}"
747+
type="button"
748+
title=${t("terminal.dockRight")}
749+
aria-label=${t("terminal.dockRight")}
750+
@click=${() => this.setDock("right")}
751+
>
752+
${DOCK_RIGHT_GLYPH}
753+
</button>
754+
<button
755+
class="tp-icon"
756+
type="button"
757+
title=${t("terminal.hide")}
758+
aria-label=${t("terminal.hide")}
759+
@click=${() => this.closePanel()}
760+
>
761+
${CLOSE_GLYPH}
762+
</button>
763+
</div>`}
736764
</header>
737765
${this.errorText
738766
? html`<div class="tp-error" role="alert">${this.errorText}</div>`
@@ -782,6 +810,10 @@ export class OpenClawTerminalPanel extends LitElement {
782810
bottom: 0;
783811
border-left: 1px solid var(--border, #262b34);
784812
}
813+
/* Terminal-only document (mobile WebViews): fill the viewport, no seams. */
814+
.tp--fullscreen {
815+
inset: 0;
816+
}
785817
.tp-resizer {
786818
position: absolute;
787819
z-index: 2;

ui/src/i18n/.i18n/ar.meta.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/i18n/.i18n/de.meta.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)