Spaces:
Running
Running
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 +4 -1
- web/src/App.tsx +59 -8
- web/src/components/SettingsView.tsx +1 -1
- web/src/styles.css +32 -2
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 |
-
|
|
|
|
| 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) {
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 305 |
<Sidebar
|
| 306 |
clis={clis}
|
| 307 |
tree={tree}
|
| 308 |
activeRef={activeRef}
|
| 309 |
focusedId={focusedId}
|
| 310 |
defaultPath={lastPath}
|
| 311 |
-
onActivate={
|
| 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 |
-
|
|
|
|
| 483 |
.sidebar { width: 230px; }
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|