breakglass UI: mobile-first rework (chat input was hidden on phones)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Mobile is the primary client for fixing the devvm, but the first cut was
desktop-first and the chat input was unreachable on a phone:
- Root cause: the shell used height:100%/100vh, so on mobile browsers the
composer at the bottom sat behind the address/tool bar — you saw the VM
buttons but no place to type. Switched #app to 100dvh (dynamic viewport
height) with a 100vh fallback; body no longer scrolls (chat scrolls
internally), killing iOS rubber-banding.
- Layout is now mobile-first single-column: the chat fills the screen with
the composer pinned at the bottom and always visible. The VM power controls
moved into a slide-up bottom sheet behind a compact "⚡ VM" header button
(backdrop + close + grab handle). At ≥900px the sheet becomes a static side
column again and the toggle is hidden — desktop unchanged.
- Touch targets ≥40px; composer textarea bumped to 16px so iOS Safari doesn't
auto-zoom on focus (which itself shoved the composer out of view).
Verified at 390×844 (iPhone) and 1280×800 via Playwright: input box renders at
y=723–821 (inside the 844 viewport), sheet slides in on tap, desktop keeps the
2-column side panel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4f361d91eb
commit
aa054cac3f
7 changed files with 194 additions and 95 deletions
|
|
@ -5,13 +5,14 @@
|
|||
import VmControls from './VmControls.svelte';
|
||||
|
||||
// ── session lifecycle ────────────────────────────────────────────────────
|
||||
// sessionId is the id we POST with. The backend also reports an authoritative
|
||||
// id in the first {kind:"session"} frame of a turn; Chat bubbles that up so
|
||||
// the rail always shows what the agent is actually resuming.
|
||||
let sessionId = $state('');
|
||||
let sessionState = $state('connecting'); // connecting | ready | error
|
||||
let sessionError = $state('');
|
||||
let streaming = $state(false); // a chat turn is in flight (drives the rail dot)
|
||||
let streaming = $state(false);
|
||||
|
||||
// Mobile: the VM controls live in a slide-up sheet. Desktop: a side column
|
||||
// (CSS hides the toggle and pins the sheet open as a column ≥900px).
|
||||
let showControls = $state(false);
|
||||
|
||||
async function newSession() {
|
||||
sessionState = 'connecting';
|
||||
|
|
@ -27,7 +28,6 @@
|
|||
|
||||
onMount(newSession);
|
||||
|
||||
// Chat reports the live session id from the stream's session frame.
|
||||
function onLiveSession(id) {
|
||||
if (id) sessionId = id;
|
||||
}
|
||||
|
|
@ -43,55 +43,75 @@
|
|||
<div class="rail-title">
|
||||
<span class="glyph" aria-hidden="true">🔧</span>
|
||||
<h1>devvm <span class="accent">breakglass</span></h1>
|
||||
<span class="rail-tag">emergency recovery</span>
|
||||
</div>
|
||||
|
||||
<div class="rail-status">
|
||||
<span class="dot dot--{dotState}" aria-hidden="true"></span>
|
||||
<span class="rail-session">
|
||||
<div class="rail-right">
|
||||
<span class="rail-status">
|
||||
<span class="dot dot--{dotState}" aria-hidden="true"></span>
|
||||
{#if sessionState === 'error'}
|
||||
<span class="session-bad">session unavailable</span>
|
||||
<span class="session-bad">offline</span>
|
||||
{:else if sessionState === 'connecting'}
|
||||
<span class="session-meta">opening session…</span>
|
||||
<span class="session-meta">connecting…</span>
|
||||
{:else}
|
||||
<span class="session-label">session</span>
|
||||
<code class="session-id" title={sessionId}>{shortId}</code>
|
||||
{#if streaming}<span class="session-meta">· agent working</span>{/if}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Mobile-only: open the VM control sheet. Hidden on desktop (column). -->
|
||||
<button
|
||||
class="controls-toggle"
|
||||
onclick={() => (showControls = true)}
|
||||
aria-label="Open direct VM controls"
|
||||
>
|
||||
⚡ <span class="controls-toggle-label">VM</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="new-session"
|
||||
onclick={newSession}
|
||||
disabled={streaming || sessionState === 'connecting'}
|
||||
title={streaming ? 'wait for the current turn to finish' : 'start a fresh session'}
|
||||
>
|
||||
New session
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if sessionState === 'error'}
|
||||
<div class="rail-error" role="alert">
|
||||
Could not reach the breakglass backend — {sessionError}. The cluster or
|
||||
network may be down. The manual VM controls below still work independently
|
||||
of the chat agent.
|
||||
Can't reach the breakglass backend — {sessionError}. The cluster or network
|
||||
may be down. The <strong>⚡ VM</strong> power controls still work without the chat.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="grid">
|
||||
<section class="col col--chat" aria-label="Recovery chat">
|
||||
<main class="stage">
|
||||
<section class="chat-pane" aria-label="Recovery chat">
|
||||
<Chat
|
||||
{sessionId}
|
||||
sessionReady={sessionState === 'ready'}
|
||||
onLiveSession={onLiveSession}
|
||||
{onLiveSession}
|
||||
onStreamingChange={(v) => (streaming = v)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside class="col col--controls" aria-label="Direct VM control">
|
||||
<aside class="controls-pane" class:open={showControls} aria-label="Direct VM control">
|
||||
<div class="sheet-grip" aria-hidden="true"></div>
|
||||
<div class="controls-head">
|
||||
<span class="controls-head-title">Direct VM control</span>
|
||||
<button class="sheet-close" onclick={() => (showControls = false)} aria-label="Close VM controls">✕</button>
|
||||
</div>
|
||||
<VmControls />
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- backdrop behind the mobile sheet -->
|
||||
<button
|
||||
class="sheet-backdrop"
|
||||
class:show={showControls}
|
||||
onclick={() => (showControls = false)}
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -101,93 +121,73 @@
|
|||
flex-direction: column;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 0 18px 18px;
|
||||
}
|
||||
|
||||
/* ── status rail ─────────────────────────────────────────────────────── */
|
||||
/* ── status rail (compact, single row on mobile) ─────────────────────── */
|
||||
.rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 4px 14px;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.rail-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
.glyph {
|
||||
font-size: 19px;
|
||||
font-size: 17px;
|
||||
transform: translateY(2px);
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 19px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.accent {
|
||||
color: var(--cyan);
|
||||
text-shadow: 0 0 18px rgba(61, 209, 214, 0.35);
|
||||
}
|
||||
.rail-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--ink-faint);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
|
||||
.rail-status {
|
||||
.rail-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
.rail-session {
|
||||
.rail-status {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-label {
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.session-id {
|
||||
color: var(--cyan);
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.session-meta {
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
}
|
||||
.session-bad {
|
||||
color: var(--danger-bright);
|
||||
}
|
||||
|
||||
/* connection lamp */
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
background: var(--ink-faint);
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
.dot--ready {
|
||||
background: var(--cyan);
|
||||
|
|
@ -203,28 +203,33 @@
|
|||
background: var(--danger);
|
||||
box-shadow: 0 0 10px 1px var(--danger-glow);
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(0.82); opacity: 0.7; }
|
||||
50% { transform: scale(1.15); opacity: 1; }
|
||||
}
|
||||
|
||||
/* touch-friendly buttons */
|
||||
.controls-toggle,
|
||||
.new-session {
|
||||
min-height: 40px;
|
||||
padding: 0 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-dim);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 13px;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.new-session:hover:not(:disabled) {
|
||||
border-color: var(--cyan-dim);
|
||||
color: var(--ink);
|
||||
.controls-toggle {
|
||||
border-color: #5a4a2a;
|
||||
color: var(--amber);
|
||||
}
|
||||
.controls-toggle:active,
|
||||
.new-session:active {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.new-session:disabled {
|
||||
|
|
@ -232,7 +237,7 @@
|
|||
}
|
||||
|
||||
.rail-error {
|
||||
margin: 12px 0 0;
|
||||
margin: 10px 12px 0;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--danger-deep);
|
||||
border-left-width: 3px;
|
||||
|
|
@ -241,32 +246,117 @@
|
|||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── layout ──────────────────────────────────────────────────────────── */
|
||||
.grid {
|
||||
/* ── stage ───────────────────────────────────────────────────────────── */
|
||||
.stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 376px;
|
||||
gap: 18px;
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
.col {
|
||||
.chat-pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: minmax(0, auto);
|
||||
overflow: auto;
|
||||
/* ── VM controls: a slide-up bottom sheet on mobile ──────────────────── */
|
||||
.controls-pane {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
max-height: 86dvh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-1);
|
||||
border-top: 1px solid var(--line-strong);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -18px 40px rgba(0, 0, 0, 0.55);
|
||||
padding: 8px 14px calc(14px + env(safe-area-inset-bottom));
|
||||
transform: translateY(101%);
|
||||
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
.controls-pane.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.sheet-grip {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
border-radius: 99px;
|
||||
background: var(--line-strong);
|
||||
margin: 4px auto 10px;
|
||||
}
|
||||
.controls-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.controls-head-title {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--amber);
|
||||
}
|
||||
.sheet-close {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sheet-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s;
|
||||
}
|
||||
.sheet-backdrop.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── desktop: controls become a static side column, sheet chrome gone ── */
|
||||
@media (min-width: 900px) {
|
||||
.rail {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.col--chat {
|
||||
min-height: 60vh;
|
||||
h1 { font-size: 19px; }
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 372px;
|
||||
gap: 16px;
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
.chat-pane { display: flex; }
|
||||
.controls-toggle { display: none; }
|
||||
.controls-pane {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
z-index: auto;
|
||||
}
|
||||
.sheet-grip,
|
||||
.controls-head,
|
||||
.sheet-backdrop { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -443,14 +443,16 @@
|
|||
flex: 1;
|
||||
resize: none;
|
||||
max-height: 168px;
|
||||
min-height: 44px;
|
||||
min-height: 48px;
|
||||
background: var(--bg-2);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 13px;
|
||||
padding: 12px 13px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
/* 16px: anything smaller makes iOS Safari auto-zoom on focus (mobile is the
|
||||
primary client) — the zoom then shifts the composer out of view. */
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ html,
|
|||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
/* The page itself never scrolls — the chat stream scrolls internally. This
|
||||
keeps the composer pinned and stops iOS rubber-banding the whole UI. */
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -79,7 +83,11 @@ body {
|
|||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
/* 100dvh (dynamic viewport height) — NOT 100vh/100% — so the composer at the
|
||||
bottom is never hidden behind a mobile browser's address/tool bar. Mobile is
|
||||
the primary client for this tool. 100vh is the fallback for old engines. */
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
button {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue