lvwerra HF Staff commited on
Commit
ef84d06
·
verified ·
1 Parent(s): 604c3c1

Mobile support: full-screen list/pane navigation, per-agent chip strip for groups, touch sizing; gate skill fan-out to Space envs

Browse files
server/src/index.js CHANGED
@@ -232,8 +232,11 @@ function skillPath(name) {
232
  }
233
 
234
  // Fan skills out as SKILL.md into the dirs every agent auto-reads, so a saved
235
- // skill is available to all of them in every new session.
 
 
236
  function skillTargetDirs() {
 
237
  const home = process.env.HOME || os.homedir();
238
  const claudeCfg = process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
239
  return [
 
232
  }
233
 
234
  // Fan skills out as SKILL.md into the dirs every agent auto-reads, so a saved
235
+ // skill is available to all of them in every new session. Gated to real Space
236
+ // deployments (or explicit opt-in): on a dev laptop these paths are the
237
+ // developer's OWN ~/.claude etc. — local test runs must not write there.
238
  function skillTargetDirs() {
239
+ if (!process.env.SPACE_ID && process.env.AM_DISTRIBUTE_SKILLS !== '1') return [];
240
  const home = process.env.HOME || os.homedir();
241
  const claudeCfg = process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
242
  return [
web/src/App.tsx CHANGED
@@ -5,10 +5,23 @@ import FilesPane from './components/FilesPane';
5
  import SettingsView from './components/SettingsView';
6
  import NewSession from './components/NewSession';
7
  import LayoutPicker from './components/LayoutPicker';
 
8
  import Locked from './components/Locked';
9
  import * as api from './api';
10
  import type { Cli, GridSpec, MoveTarget, Session, Tree } from './types';
11
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  // Auto layout: the grid grows to fit however many agents the group has.
13
  function autoGrid(n: number): GridSpec {
14
  if (n <= 1) return { cols: 1, rows: 1 };
@@ -42,6 +55,10 @@ export default function App() {
42
  const [info, setInfo] = useState<Awaited<ReturnType<typeof api.getInfo>> | null>(null);
43
  // Default location for the next agent = where the last one was created.
44
  const [lastPath, setLastPath] = useState(() => localStorage.getItem('am-last-path') || '');
 
 
 
 
45
  const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
46
  const rememberPath = (p?: string | null) => {
47
  if (p) { setLastPath(p); localStorage.setItem('am-last-path', p); }
@@ -94,12 +111,14 @@ export default function App() {
94
  );
95
 
96
  // Tile grid for the active group: the chosen layout, or auto (fit the count).
97
- const grid: GridSpec = activeGroup ? (activeGroup.layout ?? autoGrid(groupSessions.length)) : { cols: 1, rows: 1 };
 
98
  const cap = grid.cols * grid.rows;
99
  const pageCount = Math.max(1, Math.ceil(groupSessions.length / cap));
 
 
100
  const [pageRaw, setPage] = useState(0);
101
  const page = Math.min(pageRaw, pageCount - 1); // clamp when agents/layout change
102
- useEffect(() => { setPage(0); }, [activeRef]);
103
  const pageSessions = activeGroup ? groupSessions.slice(page * cap, (page + 1) * cap) : [];
104
 
105
  const visibleSessions = activeGroup ? pageSessions : activeSingle ? [activeSingle] : [];
@@ -153,10 +172,25 @@ export default function App() {
153
  const deleteSession = async (id: string) => { await api.deleteSession(id); if (activeRef === `s:${id}`) setActiveRef(null); refresh(); };
154
  // Clicking a session: nested → open its group with that pane focused; loose → solo view.
155
  const openSession = (sid: string, groupId?: string) => {
156
- if (groupId) { setActiveRef(`g:${groupId}`); setFocusedId(sid); }
157
- else setActiveRef(`s:${sid}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  };
159
  const closePane = (sid: string) => {
 
 
160
  if (activeGroup) doMove(`s:${sid}`, { kind: 'after', ref: activeRef! });
161
  else setActiveRef(null);
162
  };
@@ -301,14 +335,14 @@ export default function App() {
301
  }
302
 
303
  return (
304
- <div className="app">
305
  <Sidebar
306
  clis={clis}
307
  tree={tree}
308
  activeRef={activeRef}
309
  focusedId={focusedId}
310
  defaultPath={lastPath}
311
- onActivate={setActiveRef}
312
  onOpenSession={openSession}
313
  onNewSession={newSession}
314
  onNewGroup={newGroup}
@@ -325,6 +359,23 @@ export default function App() {
325
  />
326
 
327
  <div className="main">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  <div className="stage">
329
  {activeGroup ? (
330
  groupSessions.length === 0 ? (
@@ -354,10 +405,10 @@ export default function App() {
354
  </div>
355
  {showZoom && (
356
  <div className="zoombar">
357
- {activeGroup && groupSessions.length > 0 && (
358
  <LayoutPicker grid={grid} isAuto={!activeGroup.layout} onPick={setLayout} />
359
  )}
360
- {activeGroup && pageCount > 1 && (
361
  <span className="pager">
362
  <button className="zbtn" title="Previous panes" disabled={page === 0} onClick={() => setPage(page - 1)}>‹</button>
363
  <span className="plbl mono">{page + 1}/{pageCount}</span>
 
5
  import SettingsView from './components/SettingsView';
6
  import NewSession from './components/NewSession';
7
  import LayoutPicker from './components/LayoutPicker';
8
+ import Logo from './components/Logo';
9
  import Locked from './components/Locked';
10
  import * as api from './api';
11
  import type { Cli, GridSpec, MoveTarget, Session, Tree } from './types';
12
 
13
+ // Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
14
+ function useIsMobile() {
15
+ const [m, setM] = useState(() => window.matchMedia('(max-width: 768px)').matches);
16
+ useEffect(() => {
17
+ const mq = window.matchMedia('(max-width: 768px)');
18
+ const h = (e: MediaQueryListEvent) => setM(e.matches);
19
+ mq.addEventListener('change', h);
20
+ return () => mq.removeEventListener('change', h);
21
+ }, []);
22
+ return m;
23
+ }
24
+
25
  // Auto layout: the grid grows to fit however many agents the group has.
26
  function autoGrid(n: number): GridSpec {
27
  if (n <= 1) return { cols: 1, rows: 1 };
 
55
  const [info, setInfo] = useState<Awaited<ReturnType<typeof api.getInfo>> | null>(null);
56
  // Default location for the next agent = where the last one was created.
57
  const [lastPath, setLastPath] = useState(() => localStorage.getItem('am-last-path') || '');
58
+ // Mobile navigation: false = the sidebar is the (full-screen) home view,
59
+ // true = the selected session/group fills the screen. Desktop ignores this.
60
+ const isMobile = useIsMobile();
61
+ const [mobileStage, setMobileStage] = useState(false);
62
  const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
63
  const rememberPath = (p?: string | null) => {
64
  if (p) { setLastPath(p); localStorage.setItem('am-last-path', p); }
 
111
  );
112
 
113
  // Tile grid for the active group: the chosen layout, or auto (fit the count).
114
+ // On mobile it's always a single pane the chip strip switches between them.
115
+ const grid: GridSpec = activeGroup && !isMobile ? (activeGroup.layout ?? autoGrid(groupSessions.length)) : { cols: 1, rows: 1 };
116
  const cap = grid.cols * grid.rows;
117
  const pageCount = Math.max(1, Math.ceil(groupSessions.length / cap));
118
+ // Page resets happen explicitly in the navigation handlers (an effect on
119
+ // activeRef would clobber openSession's "land on this agent's pane").
120
  const [pageRaw, setPage] = useState(0);
121
  const page = Math.min(pageRaw, pageCount - 1); // clamp when agents/layout change
 
122
  const pageSessions = activeGroup ? groupSessions.slice(page * cap, (page + 1) * cap) : [];
123
 
124
  const visibleSessions = activeGroup ? pageSessions : activeSingle ? [activeSingle] : [];
 
172
  const deleteSession = async (id: string) => { await api.deleteSession(id); if (activeRef === `s:${id}`) setActiveRef(null); refresh(); };
173
  // Clicking a session: nested → open its group with that pane focused; loose → solo view.
174
  const openSession = (sid: string, groupId?: string) => {
175
+ if (groupId) {
176
+ setActiveRef(`g:${groupId}`);
177
+ setFocusedId(sid);
178
+ const idx = groupById[groupId]?.sessionIds.indexOf(sid) ?? -1;
179
+ setPage(isMobile && idx >= 0 ? idx : 0); // mobile: land on that agent's pane
180
+ } else {
181
+ setActiveRef(`s:${sid}`);
182
+ setPage(0);
183
+ }
184
+ if (isMobile) setMobileStage(true);
185
+ };
186
+ const activate = (ref: string) => {
187
+ setActiveRef(ref);
188
+ setPage(0);
189
+ if (isMobile) setMobileStage(true);
190
  };
191
  const closePane = (sid: string) => {
192
+ // On mobile ✕ just returns to the list — never the desktop ungroup gesture.
193
+ if (isMobile) { setMobileStage(false); return; }
194
  if (activeGroup) doMove(`s:${sid}`, { kind: 'after', ref: activeRef! });
195
  else setActiveRef(null);
196
  };
 
335
  }
336
 
337
  return (
338
+ <div className={`app${isMobile ? (mobileStage ? ' m-stage' : ' m-home') : ''}`}>
339
  <Sidebar
340
  clis={clis}
341
  tree={tree}
342
  activeRef={activeRef}
343
  focusedId={focusedId}
344
  defaultPath={lastPath}
345
+ onActivate={activate}
346
  onOpenSession={openSession}
347
  onNewSession={newSession}
348
  onNewGroup={newGroup}
 
359
  />
360
 
361
  <div className="main">
362
+ {isMobile && mobileStage && (
363
+ <div className="mbar">
364
+ <button className="icon-btn mback" onClick={() => setMobileStage(false)} title="Back to list">‹</button>
365
+ {activeGroup ? (
366
+ <div className="mchips">
367
+ {groupSessions.map((s, i) => (
368
+ <button key={s.id} className={`mchip${i === page ? ' on' : ''}`} title={s.name} onClick={() => setPage(i)}>
369
+ <Logo cli={s.cli} size={13} tint={cliMap[s.cli]?.color} />
370
+ <span className={`status ${s.state}`} />
371
+ </button>
372
+ ))}
373
+ </div>
374
+ ) : (
375
+ <span className="mtitle mono">{activeSingle?.name}</span>
376
+ )}
377
+ </div>
378
+ )}
379
  <div className="stage">
380
  {activeGroup ? (
381
  groupSessions.length === 0 ? (
 
405
  </div>
406
  {showZoom && (
407
  <div className="zoombar">
408
+ {!isMobile && activeGroup && groupSessions.length > 0 && (
409
  <LayoutPicker grid={grid} isAuto={!activeGroup.layout} onPick={setLayout} />
410
  )}
411
+ {!isMobile && activeGroup && pageCount > 1 && (
412
  <span className="pager">
413
  <button className="zbtn" title="Previous panes" disabled={page === 0} onClick={() => setPage(page - 1)}>‹</button>
414
  <span className="plbl mono">{page + 1}/{pageCount}</span>
web/src/components/SettingsView.tsx CHANGED
@@ -47,7 +47,7 @@ export default function SettingsView({
47
  } catch { setRelaunch({ msg: 'Request failed.' }); }
48
  };
49
  return (
50
- <div className="app">
51
  <aside className="sidebar">
52
  <div className="brand">
53
  <button className="icon-btn" onClick={onClose} title="Back">←</button>
 
47
  } catch { setRelaunch({ msg: 'Request failed.' }); }
48
  };
49
  return (
50
+ <div className="app settings">
51
  <aside className="sidebar">
52
  <div className="brand">
53
  <button className="icon-btn" onClick={onClose} title="Back">←</button>
web/src/styles.css CHANGED
@@ -479,7 +479,37 @@ body {
479
  .qfill { height: 100%; background: var(--accent); border-radius: 999px; }
480
  .qpct { width: 130px; text-align: right; flex: none; font-variant-numeric: tabular-nums; }
481
 
482
- @media (max-width: 820px) {
 
483
  .sidebar { width: 230px; }
484
- .tiles { grid-template-columns: 1fr !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  }
 
479
  .qfill { height: 100%; background: var(--accent); border-radius: 999px; }
480
  .qpct { width: 130px; text-align: right; flex: none; font-variant-numeric: tabular-nums; }
481
 
482
+ /* narrow desktop */
483
+ @media (min-width: 769px) and (max-width: 900px) {
484
  .sidebar { width: 230px; }
485
+ }
486
+
487
+ /* mobile stage top bar (back + agent chips) — rendered only on mobile */
488
+ .mbar { display: flex; align-items: center; gap: 8px; padding: 0 0 8px; flex: none; }
489
+ .mback { flex: none; font-size: 19px; padding-bottom: 3px; }
490
+ .mtitle { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
491
+ .mchips { display: flex; gap: 6px; overflow-x: auto; flex: 1; padding: 2px; }
492
+ .mchip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-md); cursor: pointer; flex: none; }
493
+ .mchip.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
494
+
495
+ /* phones: two full-screen views — the sidebar list, or the selected pane */
496
+ @media (max-width: 768px) {
497
+ .app { height: 100dvh; }
498
+ .app.m-home .main { display: none; }
499
+ .app.m-home .sidebar { width: 100%; border-right: none; }
500
+ .app.m-stage .sidebar { display: none; }
501
+ .app.m-stage .main { padding: 8px; }
502
+ /* comfortable touch targets */
503
+ .row { padding: 9px 10px; }
504
+ .row .row-actions { opacity: 1; pointer-events: auto; position: static; transform: none; margin-left: 4px; }
505
+ .row .cli-logo, .row .count { opacity: 1 !important; }
506
+ .caret { width: 22px; font-size: 15px; }
507
+ /* 16px inputs stop iOS zoom-on-focus */
508
+ .widget input, .widget select, .row .rename, .secret-desc, .fp-input, .pane-head .ph-title-input { font-size: 16px; }
509
+ /* settings stacks vertically */
510
+ .app.settings { flex-direction: column; }
511
+ .app.settings .sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
512
+ .app.settings .settings-nav { flex-direction: row; gap: 6px; padding: 8px 10px; }
513
+ .app.settings .settings-main { flex: 1; }
514
+ .settings-page { padding: 6px 12px 30px; }
515
  }