Agent Manager Claude Opus 5 commited on
Commit
d553048
·
1 Parent(s): 106a5ed

Mobile: the way back rides in the pane header

Browse files

On a phone the session window carried a bar of its own above it, holding a back
arrow and the session's name. The name was already in the pane header directly
below it, so the bar's only unique job was the arrow — and it charged a row of
the one dimension a terminal has too little of.

The arrow moves into the pane header, immediately left of the agent's logo, as
the operator suggested. The bar goes with it: at 390px the terminal grows from
544px to 582px, and the header it moved into is not one pixel taller, because
the button stretches into the header's existing padding rather than adding any.

The bar survives wherever nothing else can hold the arrow, which is the part
worth reviewing:

- a group, whose bar is also the chip pager between its agents
- the Overview, which has no pane header at all
- the files and trace panes, whose flat headers have no identity block — and
whose leftmost spot is, for files, already a back button to the file list, so
a second arrow there would be two backs meaning two different things

That rule is `paneOwnsBack` in lib/mobileBack.ts, tested rather than inlined,
because it decides whether a surface has any way back at all.

Also: the terminal/reader switch takes the left edge on a phone. It sat adrift
mid-bar because the spacer that pushes the zoom keys right on a wide window was
pushing it too; ordering it ahead of the spacer gives it the left and leaves
zoom on the right.

Verified with playwright at 320/390/768/1280, before and after. Desktop is
untouched: every measured number identical, and a chrome-only pixel diff differs
by 0-17px against a 7-39px noise floor measured by running the same build twice.
No page scrolls sideways at any width (#71 holds). 13 browser checks on the
behaviour, 13 unit checks on the rule, web and server suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

web/package.json CHANGED
@@ -13,7 +13,7 @@
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
- "test": "node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs && node test/drafts.test.mjs && node test/traceWindows.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
 
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
+ "test": "node test/mobileBack.test.mjs && node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs && node test/drafts.test.mjs && node test/traceWindows.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
web/src/App.tsx CHANGED
@@ -19,6 +19,7 @@ import type { Cli, GridSpec, MoveTarget, OverviewChip, OverviewSort, Session, Tr
19
  import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode';
20
  import { hiddenSessionIds } from './lib/overviewHidden';
21
  import { useReaderBatch } from './lib/readerBatch';
 
22
  import { isPassive, isRemote } from './types';
23
  import { EyeGlyph, EyeOffGlyph, GridGlyph, ListGlyph, SortGlyph } from './components/icons';
24
  import { uploadPendingAttachments } from './lib/attachments';
@@ -516,6 +517,13 @@ export default function App() {
516
  [activeGroup, sessById],
517
  );
518
 
 
 
 
 
 
 
 
519
  // Tile grid for the active group: the chosen layout, or auto (fit the count).
520
  // On mobile it's always a single pane — the chip strip switches between them.
521
  const grid: GridSpec = activeGroup && !isMobile ? (activeGroup.layout ?? autoGrid(groupSessions.length)) : { cols: 1, rows: 1 };
@@ -903,6 +911,7 @@ export default function App() {
903
  active={shown && deckVisible && s.id === focusedId}
904
  dragId={shown && canDrag ? `p:${s.id}` : undefined}
905
  isMobile={isMobile}
 
906
  onDragActive={setPaneDrag}
907
  onFocus={() => setFocusedId(s.id)}
908
  onRename={(name) => renameSession(s.id, name)}
@@ -935,6 +944,7 @@ export default function App() {
935
  groupName={groupNameOf[s.id]}
936
  focused={visibleSessions.length > 1 && s.id === focusedId}
937
  dragId={canDrag ? `p:${s.id}` : undefined}
 
938
  onDragActive={setPaneDrag}
939
  onFocus={() => setFocusedId(s.id)}
940
  onRename={(name) => renameSession(s.id, name)}
@@ -1063,7 +1073,7 @@ export default function App() {
1063
  />
1064
 
1065
  <div className="main">
1066
- {isMobile && mobileStage && (
1067
  <div className="mbar">
1068
  <button className="icon-btn mback" onClick={() => setMobileStage(false)} title="Back to list">‹</button>
1069
  {activeGroup ? (
 
19
  import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode';
20
  import { hiddenSessionIds } from './lib/overviewHidden';
21
  import { useReaderBatch } from './lib/readerBatch';
22
+ import { paneOwnsBack } from './lib/mobileBack';
23
  import { isPassive, isRemote } from './types';
24
  import { EyeGlyph, EyeOffGlyph, GridGlyph, ListGlyph, SortGlyph } from './components/icons';
25
  import { uploadPendingAttachments } from './lib/attachments';
 
517
  [activeGroup, sessById],
518
  );
519
 
520
+ // A staged agent carries its own way back, in its header left of the logo, and
521
+ // the bar above it goes away with the row it cost. See mobileBack.ts for which
522
+ // surfaces still need the bar and why.
523
+ const ownsBack = paneOwnsBack({
524
+ isMobile, staged: mobileStage, inGroup: !!activeGroup, cli: activeSingle?.cli,
525
+ });
526
+
527
  // Tile grid for the active group: the chosen layout, or auto (fit the count).
528
  // On mobile it's always a single pane — the chip strip switches between them.
529
  const grid: GridSpec = activeGroup && !isMobile ? (activeGroup.layout ?? autoGrid(groupSessions.length)) : { cols: 1, rows: 1 };
 
911
  active={shown && deckVisible && s.id === focusedId}
912
  dragId={shown && canDrag ? `p:${s.id}` : undefined}
913
  isMobile={isMobile}
914
+ onBack={ownsBack ? () => setMobileStage(false) : undefined}
915
  onDragActive={setPaneDrag}
916
  onFocus={() => setFocusedId(s.id)}
917
  onRename={(name) => renameSession(s.id, name)}
 
944
  groupName={groupNameOf[s.id]}
945
  focused={visibleSessions.length > 1 && s.id === focusedId}
946
  dragId={canDrag ? `p:${s.id}` : undefined}
947
+ onBack={ownsBack ? () => setMobileStage(false) : undefined}
948
  onDragActive={setPaneDrag}
949
  onFocus={() => setFocusedId(s.id)}
950
  onRename={(name) => renameSession(s.id, name)}
 
1073
  />
1074
 
1075
  <div className="main">
1076
+ {isMobile && mobileStage && !ownsBack && (
1077
  <div className="mbar">
1078
  <button className="icon-btn mback" onClick={() => setMobileStage(false)} title="Back to list">‹</button>
1079
  {activeGroup ? (
web/src/components/RemotePane.tsx CHANGED
@@ -5,7 +5,7 @@ import * as api from '../api';
5
  import Logo from './Logo';
6
  import { renderMarkdown } from '../lib/markdown';
7
  import { groupLabel, sessionTitle } from '../lib/sessionTitle';
8
- import { CloseGlyph, StopGlyph, PlayGlyph, ShareGlyph, AckGlyph } from './icons';
9
 
10
  // Looks like the terminal, is not one: no PTY, no xterm.js, no WebSocket. The
11
  // agent's real TUI is running on its own machine — what crosses the wire is
@@ -25,13 +25,14 @@ const fmtAgo = (ts?: number | null) => {
25
  };
26
 
27
  export default function RemotePane({
28
- session, focused, zoom = 100, dragId, groupName, onDragActive, onFocus, onClose, onRename,
29
  }: {
30
  session: Session;
31
  groupName?: string | null; // the group this pane belongs to, if any
32
  focused?: boolean;
33
  zoom?: number;
34
  dragId?: string;
 
35
  onDragActive?: (dragging: boolean) => void;
36
  onFocus?: () => void;
37
  onClose: () => void;
@@ -207,6 +208,20 @@ export default function RemotePane({
207
  onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
208
  >
209
  <div className="ph-left">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  <Logo cli="remote" size={16} tint="#5ec2e0" />
211
  <span className={`status ${state}`} title={stateLabel} />
212
  </div>
 
5
  import Logo from './Logo';
6
  import { renderMarkdown } from '../lib/markdown';
7
  import { groupLabel, sessionTitle } from '../lib/sessionTitle';
8
+ import { BackGlyph, CloseGlyph, StopGlyph, PlayGlyph, ShareGlyph, AckGlyph } from './icons';
9
 
10
  // Looks like the terminal, is not one: no PTY, no xterm.js, no WebSocket. The
11
  // agent's real TUI is running on its own machine — what crosses the wire is
 
25
  };
26
 
27
  export default function RemotePane({
28
+ session, focused, zoom = 100, dragId, groupName, onBack, onDragActive, onFocus, onClose, onRename,
29
  }: {
30
  session: Session;
31
  groupName?: string | null; // the group this pane belongs to, if any
32
  focused?: boolean;
33
  zoom?: number;
34
  dragId?: string;
35
+ onBack?: () => void; // mobile: leave the pane for the list (see .ph-back)
36
  onDragActive?: (dragging: boolean) => void;
37
  onFocus?: () => void;
38
  onClose: () => void;
 
208
  onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
209
  >
210
  <div className="ph-left">
211
+ {onBack && (
212
+ // See TerminalPane: on a phone the way back rides in the header
213
+ // rather than costing the pane a bar of its own.
214
+ <button
215
+ className="mini-btn ph-back"
216
+ title="Back to list"
217
+ aria-label="Back to list"
218
+ draggable={false}
219
+ onMouseDown={(e) => e.stopPropagation()}
220
+ onClick={(e) => { e.stopPropagation(); onBack(); }}
221
+ >
222
+ <BackGlyph />
223
+ </button>
224
+ )}
225
  <Logo cli="remote" size={16} tint="#5ec2e0" />
226
  <span className={`status ${state}`} title={stateLabel} />
227
  </div>
web/src/components/TerminalPane.tsx CHANGED
@@ -12,7 +12,7 @@ import ConversationView from './conversation/ConversationView';
12
  import { isPassive } from '../types';
13
  import type { PaneMode } from '../lib/paneMode';
14
  import { groupLabel, sessionTitle } from '../lib/sessionTitle';
15
- import { CloseGlyph, RefreshGlyph } from './icons';
16
  import * as api from '../api';
17
  import type { Attachment } from '../api';
18
  import {
@@ -189,7 +189,7 @@ if (typeof window !== 'undefined') {
189
 
190
  export default function TerminalPane({
191
  session, cli, theme, focused, visible, active, zoom = 100, mode = 'terminal', readerEnabled,
192
- readerReadyKey, onReaderReady, dragId, isMobile, groupName, onDragActive, onFocus, onRename, onClose,
193
  }: {
194
  session: Session;
195
  cli?: Cli;
@@ -205,6 +205,7 @@ export default function TerminalPane({
205
  onReaderReady?: () => void;
206
  dragId?: string; // set when the pane can be rearranged (group view)
207
  isMobile?: boolean; // show the on-screen control-key bar
 
208
  onDragActive?: (dragging: boolean) => void;
209
  onFocus?: () => void;
210
  onRename?: (name: string) => void;
@@ -1021,6 +1022,20 @@ export default function TerminalPane({
1021
  onMouseDown={(e) => { if (!dragId) e.preventDefault(); onFocus?.(); focusTerm(); }}
1022
  >
1023
  <div className="ph-left">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1024
  <Logo cli={session.cli} size={16} tint={tint} />
1025
  <span className={`status ${session.state}`} title={`${STATE_LABEL[session.state]} · ${conn}`} />
1026
  </div>
 
12
  import { isPassive } from '../types';
13
  import type { PaneMode } from '../lib/paneMode';
14
  import { groupLabel, sessionTitle } from '../lib/sessionTitle';
15
+ import { BackGlyph, CloseGlyph, RefreshGlyph } from './icons';
16
  import * as api from '../api';
17
  import type { Attachment } from '../api';
18
  import {
 
189
 
190
  export default function TerminalPane({
191
  session, cli, theme, focused, visible, active, zoom = 100, mode = 'terminal', readerEnabled,
192
+ readerReadyKey, onReaderReady, dragId, isMobile, groupName, onBack, onDragActive, onFocus, onRename, onClose,
193
  }: {
194
  session: Session;
195
  cli?: Cli;
 
205
  onReaderReady?: () => void;
206
  dragId?: string; // set when the pane can be rearranged (group view)
207
  isMobile?: boolean; // show the on-screen control-key bar
208
+ onBack?: () => void; // mobile: leave the pane for the list (see .ph-back)
209
  onDragActive?: (dragging: boolean) => void;
210
  onFocus?: () => void;
211
  onRename?: (name: string) => void;
 
1022
  onMouseDown={(e) => { if (!dragId) e.preventDefault(); onFocus?.(); focusTerm(); }}
1023
  >
1024
  <div className="ph-left">
1025
+ {onBack && (
1026
+ // Sits in the header rather than in a bar of its own above it: a
1027
+ // phone pays for that bar in the one dimension the terminal needs.
1028
+ <button
1029
+ className="mini-btn ph-back"
1030
+ title="Back to list"
1031
+ aria-label="Back to list"
1032
+ draggable={false}
1033
+ onMouseDown={(e) => e.stopPropagation()}
1034
+ onClick={(e) => { e.stopPropagation(); onBack(); }}
1035
+ >
1036
+ <BackGlyph />
1037
+ </button>
1038
+ )}
1039
  <Logo cli={session.cli} size={16} tint={tint} />
1040
  <span className={`status ${session.state}`} title={`${STATE_LABEL[session.state]} · ${conn}`} />
1041
  </div>
web/src/lib/mobileBack.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { isPassive } from '../types';
2
+
3
+ /**
4
+ * Does the staged pane carry its own way back, in its header?
5
+ *
6
+ * On a phone the app is two full-screen views — the list, or one staged pane —
7
+ * so a staged pane needs a way back to the list. It used to be a bar above the
8
+ * pane holding an arrow and the session's name, and that bar cost a row of the
9
+ * one dimension a terminal actually needs. An agent's pane header already has
10
+ * an identity block (logo and status) on its left and already shows the name,
11
+ * so the arrow goes there and the bar goes away.
12
+ *
13
+ * The bar survives wherever nothing else can hold the arrow:
14
+ * - a group, whose bar is also the chip pager between its agents;
15
+ * - the Overview, which has no pane header at all;
16
+ * - the files and trace panes, whose flat headers have no identity block —
17
+ * and whose leftmost spot is, for files, already a back button to the file
18
+ * list, so a second arrow there would be two backs meaning two things.
19
+ *
20
+ * Desktop never stages and never asks: the sidebar is always on screen.
21
+ */
22
+ export const paneOwnsBack = (
23
+ { isMobile, staged, inGroup, cli }: {
24
+ isMobile: boolean;
25
+ staged: boolean;
26
+ inGroup: boolean;
27
+ cli?: string | null;
28
+ },
29
+ ): boolean => isMobile && staged && !inGroup && !!cli && !isPassive(cli);
web/src/styles.css CHANGED
@@ -652,7 +652,11 @@ body {
652
  already reachable with a long enough agent name; putting the group in front
653
  of the name made it reachable at ordinary ones. With the floor the title is
654
  capped at what is actually free and ellipsises instead. */
655
- .pane-head { container-type: inline-size; display: grid; grid-template-columns: minmax(min-content, 1fr) minmax(0, auto) minmax(min-content, 1fr); align-items: center; gap: 9px; padding: 7px 11px; background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; flex: none; }
 
 
 
 
656
  .pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
657
  .pane-head .ph-title { grid-column: 2; min-width: 0; max-width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 5px; font-family: var(--font-mono); font-weight: 600; white-space: nowrap; overflow: hidden; cursor: text; }
658
  /* `[Group] name` is one title in two parts, and the group is the only part that
@@ -671,6 +675,12 @@ body {
671
  .pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; }
672
  .pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
673
  .pane-head .ph-close { justify-self: end; }
 
 
 
 
 
 
674
  @container (max-width: 520px) {
675
  .pane-head .ph-path { display: none; }
676
  }
@@ -1240,6 +1250,11 @@ a.btn-ghost { text-decoration: none; }
1240
  width: 1px !important;
1241
  height: 1px !important;
1242
  }
 
 
 
 
 
1243
  .app.m-home .main { display: none; }
1244
  .app.m-home .sidebar { width: 100%; border-right: none; }
1245
  .app.m-stage .sidebar { display: none; }
 
652
  already reachable with a long enough agent name; putting the group in front
653
  of the name made it reachable at ordinary ones. With the floor the title is
654
  capped at what is actually free and ellipsises instead. */
655
+ /* The vertical padding is a variable because .ph-back cancels it to reach the
656
+ header's full height without adding any (below). A bleed that restated the
657
+ number would drift the first time this padding changed — the same way a
658
+ hardcoded 14px against an 8px gutter gave the phone a sideways scroll. */
659
+ .pane-head { --ph-pad-y: 7px; container-type: inline-size; display: grid; grid-template-columns: minmax(min-content, 1fr) minmax(0, auto) minmax(min-content, 1fr); align-items: center; gap: 9px; padding: var(--ph-pad-y) 11px; background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; flex: none; }
660
  .pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
661
  .pane-head .ph-title { grid-column: 2; min-width: 0; max-width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 5px; font-family: var(--font-mono); font-weight: 600; white-space: nowrap; overflow: hidden; cursor: text; }
662
  /* `[Group] name` is one title in two parts, and the group is the only part that
 
675
  .pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; }
676
  .pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
677
  .pane-head .ph-close { justify-self: end; }
678
+ /* The way back on a phone, left of the agent's logo. It stretches to the
679
+ header's own height — cancelling that height's padding rather than adding any
680
+ — because a taller header would hand straight back the row that dropping the
681
+ bar above it won (see .mbar). */
682
+ .pane-head .ph-back { align-self: stretch; margin: calc(-1 * var(--ph-pad-y)) 1px calc(-1 * var(--ph-pad-y)) -5px; padding: 0 8px; }
683
+ .pane-head .ph-back svg { width: 15px; height: 15px; }
684
  @container (max-width: 520px) {
685
  .pane-head .ph-path { display: none; }
686
  }
 
1250
  width: 1px !important;
1251
  height: 1px !important;
1252
  }
1253
+ /* Reading mode is the switch you reach for most on a phone, and it sat adrift
1254
+ in the middle of the bar: the spacer that pushes the zoom keys right on a
1255
+ wide window was pushing this too. Order it ahead of the spacer instead, so
1256
+ it takes the left edge and zoom keeps the right. */
1257
+ .zoombar .modebar { order: -1; }
1258
  .app.m-home .main { display: none; }
1259
  .app.m-home .sidebar { width: 100%; border-right: none; }
1260
  .app.m-stage .sidebar { display: none; }
web/test/mobileBack.test.mjs ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Which mobile surface carries its own way back, and which still needs the bar
2
+ // above it. The rule decides whether a phone spends a row of vertical space on
3
+ // chrome, and the cases that matter are the ones where dropping the bar would
4
+ // leave no way back at all.
5
+ //
6
+ // Same shape as sessionTitle.test.mjs, bundled because the rule shares the
7
+ // passive-CLI list with types.ts rather than restating it. Run with:
8
+ // node test/mobileBack.test.mjs
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { build } from 'esbuild';
15
+
16
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
17
+ const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'mback-')), 'mobileBack.mjs');
18
+ await build({
19
+ entryPoints: [path.join(HERE, '../src/lib/mobileBack.ts')],
20
+ outfile: out, format: 'esm', bundle: true, logLevel: 'error',
21
+ });
22
+ const { paneOwnsBack } = await import(pathToFileURL(out).href);
23
+
24
+ let failed = 0;
25
+ const check = (what, fn) => {
26
+ try { fn(); console.log(` ok ${what}`); } catch (e) {
27
+ failed++;
28
+ console.log(` FAIL ${what}\n ${e.message.split('\n')[0]}`);
29
+ }
30
+ };
31
+ // A staged agent on a phone, unless a case below says otherwise.
32
+ const staged = { isMobile: true, staged: true, inGroup: false, cli: 'claude' };
33
+
34
+ console.log('an agent pane carries its own way back');
35
+ check('a staged agent', () => assert.equal(paneOwnsBack(staged), true));
36
+ check('whichever agent it is', () => assert.equal(paneOwnsBack({ ...staged, cli: 'codex' }), true));
37
+ check('a remote agent too — same header, same identity block',
38
+ () => assert.equal(paneOwnsBack({ ...staged, cli: 'remote' }), true));
39
+ check('a shell', () => assert.equal(paneOwnsBack({ ...staged, cli: 'shell' }), true));
40
+
41
+ console.log('\nand the bar stays wherever nothing else can hold the arrow');
42
+ check('a group — its bar is also the pager between its agents',
43
+ () => assert.equal(paneOwnsBack({ ...staged, inGroup: true }), false));
44
+ check('the files pane — its own leftmost button already means "back to the files"',
45
+ () => assert.equal(paneOwnsBack({ ...staged, cli: 'files' }), false));
46
+ check('the trace pane — a flat header with no identity block',
47
+ () => assert.equal(paneOwnsBack({ ...staged, cli: 'trace' }), false));
48
+ check('the overview — no pane header at all, so no cli either',
49
+ () => assert.equal(paneOwnsBack({ ...staged, cli: null }), false));
50
+ check('…and undefined reads the same as null',
51
+ () => assert.equal(paneOwnsBack({ ...staged, cli: undefined }), false));
52
+
53
+ console.log('\nand nowhere it would be meaningless');
54
+ check('not on the list itself, where there is nothing to go back from',
55
+ () => assert.equal(paneOwnsBack({ ...staged, staged: false }), false));
56
+ check('not on desktop, where the sidebar never leaves',
57
+ () => assert.equal(paneOwnsBack({ ...staged, isMobile: false }), false));
58
+ check('not on a desktop group either',
59
+ () => assert.equal(paneOwnsBack({ ...staged, isMobile: false, inGroup: true }), false));
60
+
61
+ console.log(failed ? `\n${failed} failed` : '\nall checks passed');
62
+ process.exit(failed ? 1 : 0);