Spaces:
Running
Running
Overview: full cards for empty agents, filter as bottom seg bar; install page redesign (frozen sidebar mock + 2-step manual guide with screenshots)
Browse files- .gitattributes +1 -0
- web/public/install/duplicate-space.png +0 -0
- web/public/install/mount-bucket.png +3 -0
- web/src/App.tsx +16 -2
- web/src/components/Locked.tsx +131 -30
- web/src/components/Overview.tsx +12 -48
- web/src/styles.css +23 -21
- web/src/types.ts +2 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
web/public/install/mount-bucket.png filter=lfs diff=lfs merge=lfs -text
|
web/public/install/duplicate-space.png
ADDED
|
web/public/install/mount-bucket.png
ADDED
|
Git LFS Details
|
web/src/App.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import Logo from './components/Logo';
|
|
| 9 |
import Overview from './components/Overview';
|
| 10 |
import Locked from './components/Locked';
|
| 11 |
import * as api from './api';
|
| 12 |
-
import type { Cli, GridSpec, MoveTarget, Session, Tree } from './types';
|
| 13 |
|
| 14 |
// Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
|
| 15 |
function useIsMobile() {
|
|
@@ -60,6 +60,7 @@ export default function App() {
|
|
| 60 |
// true = the selected session/group fills the screen. Desktop ignores this.
|
| 61 |
const isMobile = useIsMobile();
|
| 62 |
const [mobileStage, setMobileStage] = useState(false);
|
|
|
|
| 63 |
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
| 64 |
const rememberPath = (p?: string | null) => {
|
| 65 |
if (p) { setLastPath(p); localStorage.setItem('am-last-path', p); }
|
|
@@ -377,7 +378,7 @@ export default function App() {
|
|
| 377 |
))}
|
| 378 |
</div>
|
| 379 |
) : (
|
| 380 |
-
<span className="mtitle mono">{activeSingle?.name}</span>
|
| 381 |
)}
|
| 382 |
</div>
|
| 383 |
)}
|
|
@@ -386,6 +387,7 @@ export default function App() {
|
|
| 386 |
<Overview
|
| 387 |
clis={clis}
|
| 388 |
tree={tree}
|
|
|
|
| 389 |
onOpen={(sid) => {
|
| 390 |
const g = tree.groups.find((x) => x.sessionIds.includes(sid));
|
| 391 |
openSession(sid, g?.id);
|
|
@@ -417,6 +419,18 @@ export default function App() {
|
|
| 417 |
</div>
|
| 418 |
)}
|
| 419 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
{showZoom && (
|
| 421 |
<div className="zoombar">
|
| 422 |
{!isMobile && activeGroup && groupSessions.length > 0 && (
|
|
|
|
| 9 |
import Overview from './components/Overview';
|
| 10 |
import Locked from './components/Locked';
|
| 11 |
import * as api from './api';
|
| 12 |
+
import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
|
| 13 |
|
| 14 |
// Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
|
| 15 |
function useIsMobile() {
|
|
|
|
| 60 |
// true = the selected session/group fills the screen. Desktop ignores this.
|
| 61 |
const isMobile = useIsMobile();
|
| 62 |
const [mobileStage, setMobileStage] = useState(false);
|
| 63 |
+
const [ovFilter, setOvFilter] = useState<OverviewFilter>('all');
|
| 64 |
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
| 65 |
const rememberPath = (p?: string | null) => {
|
| 66 |
if (p) { setLastPath(p); localStorage.setItem('am-last-path', p); }
|
|
|
|
| 378 |
))}
|
| 379 |
</div>
|
| 380 |
) : (
|
| 381 |
+
<span className="mtitle mono">{activeRef === 'overview' ? 'Overview' : activeSingle?.name}</span>
|
| 382 |
)}
|
| 383 |
</div>
|
| 384 |
)}
|
|
|
|
| 387 |
<Overview
|
| 388 |
clis={clis}
|
| 389 |
tree={tree}
|
| 390 |
+
filter={ovFilter}
|
| 391 |
onOpen={(sid) => {
|
| 392 |
const g = tree.groups.find((x) => x.sessionIds.includes(sid));
|
| 393 |
openSession(sid, g?.id);
|
|
|
|
| 419 |
</div>
|
| 420 |
)}
|
| 421 |
</div>
|
| 422 |
+
{activeRef === 'overview' && (
|
| 423 |
+
<div className="zoombar">
|
| 424 |
+
<div className="seg ov-seg">
|
| 425 |
+
{(['all', 'waiting', 'working', 'quiet'] as OverviewFilter[]).map((f) => (
|
| 426 |
+
<button key={f} className={ovFilter === f ? 'on' : ''} onClick={() => setOvFilter(f)}>
|
| 427 |
+
{f === 'waiting' ? 'your turn' : f}
|
| 428 |
+
</button>
|
| 429 |
+
))}
|
| 430 |
+
</div>
|
| 431 |
+
<span className="spacer" />
|
| 432 |
+
</div>
|
| 433 |
+
)}
|
| 434 |
{showZoom && (
|
| 435 |
<div className="zoombar">
|
| 436 |
{!isMobile && activeGroup && groupSessions.length > 0 && (
|
web/src/components/Locked.tsx
CHANGED
|
@@ -1,8 +1,80 @@
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
-
import { LockGlyph } from './icons';
|
|
|
|
| 3 |
|
| 4 |
-
//
|
| 5 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
export default function Locked({ spaceId }: { spaceId?: string | null }) {
|
| 7 |
const id = spaceId || 'owner/space-name';
|
| 8 |
const cmd = `from huggingface_hub import HfApi, Volume, create_bucket
|
|
@@ -29,36 +101,65 @@ api.duplicate_repo(
|
|
| 29 |
const copy = () => {
|
| 30 |
navigator.clipboard?.writeText(cmd).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }).catch(() => {});
|
| 31 |
};
|
|
|
|
| 32 |
return (
|
| 33 |
-
<div className="locked">
|
| 34 |
-
<
|
| 35 |
-
|
| 36 |
-
<
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
</
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
</div>
|
| 63 |
</div>
|
| 64 |
);
|
|
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
+
import { LockGlyph, SlidersGlyph, SunGlyph, PulseGlyph } from './icons';
|
| 3 |
+
import Logo from './Logo';
|
| 4 |
|
| 5 |
+
// A frozen, slightly dimmed replica of the real sidebar so the install page
|
| 6 |
+
// looks like the product it unlocks. Pure decoration: no handlers, no state.
|
| 7 |
+
type MockSession = { name: string; cli: string; tint: string; state: string };
|
| 8 |
+
type MockItem = MockSession | { group: string; members: MockSession[] };
|
| 9 |
+
const MOCK: MockItem[] = [
|
| 10 |
+
{ group: 'research', members: [
|
| 11 |
+
{ name: 'writer', cli: 'claude', tint: '#d97757', state: 'working' },
|
| 12 |
+
{ name: 'reviewer', cli: 'codex', tint: '#5eb6a6', state: 'waiting' },
|
| 13 |
+
] },
|
| 14 |
+
{ name: 'refactor-bot', cli: 'claude', tint: '#d97757', state: 'idle' },
|
| 15 |
+
{ name: 'gemini-cli-1', cli: 'gemini', tint: '#4796e3', state: 'stopped' },
|
| 16 |
+
{ name: 'shell-1', cli: 'shell', tint: '#8aa0ad', state: 'stopped' },
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
function MockRow({ s, nested }: { s: MockSession; nested?: boolean }) {
|
| 20 |
+
return (
|
| 21 |
+
<div className={`row session${nested ? ' nested' : ''}`}>
|
| 22 |
+
<span className={`status ${s.state}`} />
|
| 23 |
+
<span className="name">{s.name}</span>
|
| 24 |
+
<Logo cli={s.cli} size={11} tint={s.tint} />
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function MockSidebar() {
|
| 30 |
+
return (
|
| 31 |
+
<aside className="sidebar mock-side" aria-hidden="true">
|
| 32 |
+
<div className="brand">
|
| 33 |
+
<div className="logo"><span className="dot agg-working" /><h1>Agent Manager</h1></div>
|
| 34 |
+
<div className="brand-actions">
|
| 35 |
+
<span className="icon-btn"><SlidersGlyph /></span>
|
| 36 |
+
<span className="icon-btn"><SunGlyph /></span>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
<div className="controls">
|
| 40 |
+
<div className="add-row">
|
| 41 |
+
<span className="btn-ghost">+ Agent</span>
|
| 42 |
+
<span className="btn-ghost">+ Group</span>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
<div className="ov-fixed">
|
| 46 |
+
<div className="row ov-row"><PulseGlyph className="ov-row-ico" /><span className="name">Overview</span></div>
|
| 47 |
+
</div>
|
| 48 |
+
<div className="tree">
|
| 49 |
+
{MOCK.map((item) => 'group' in item ? (
|
| 50 |
+
<div key={item.group} className="group">
|
| 51 |
+
<div className="row group-head">
|
| 52 |
+
<span className="caret">▾</span>
|
| 53 |
+
<span className="name">{item.group}</span>
|
| 54 |
+
<span className="count">{item.members.length}</span>
|
| 55 |
+
</div>
|
| 56 |
+
{item.members.map((m) => <MockRow key={m.name} s={m} nested />)}
|
| 57 |
+
</div>
|
| 58 |
+
) : (
|
| 59 |
+
<MockRow key={item.name} s={item} />
|
| 60 |
+
))}
|
| 61 |
+
</div>
|
| 62 |
+
<div className="quick-add">
|
| 63 |
+
<span className="btn-ghost"><Logo cli="shell" size={14} /> Shell</span>
|
| 64 |
+
<span className="btn-ghost"><Logo cli="files" size={14} /> Files</span>
|
| 65 |
+
</div>
|
| 66 |
+
<div className="legend">
|
| 67 |
+
<span><span className="status working" /> working</span>
|
| 68 |
+
<span><span className="status waiting" /> your turn</span>
|
| 69 |
+
<span><span className="status idle" /> idle</span>
|
| 70 |
+
<span><span className="status stopped" /> stopped</span>
|
| 71 |
+
</div>
|
| 72 |
+
</aside>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// Shown when the server reports the Space is public: the terminal backend is
|
| 77 |
+
// disabled server-side and this page explains how to run a private copy.
|
| 78 |
export default function Locked({ spaceId }: { spaceId?: string | null }) {
|
| 79 |
const id = spaceId || 'owner/space-name';
|
| 80 |
const cmd = `from huggingface_hub import HfApi, Volume, create_bucket
|
|
|
|
| 101 |
const copy = () => {
|
| 102 |
navigator.clipboard?.writeText(cmd).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }).catch(() => {});
|
| 103 |
};
|
| 104 |
+
|
| 105 |
return (
|
| 106 |
+
<div className="app locked-app">
|
| 107 |
+
<MockSidebar />
|
| 108 |
+
<div className="main locked-main">
|
| 109 |
+
<div className="install">
|
| 110 |
+
<div className="install-head">
|
| 111 |
+
<span className="install-lock"><LockGlyph /></span>
|
| 112 |
+
<div>
|
| 113 |
+
<h1>Your own Agent Manager, in two steps</h1>
|
| 114 |
+
<p className="locked-lead">
|
| 115 |
+
A private cloud workspace for Claude Code, Codex, Gemini CLI and friends —
|
| 116 |
+
dispatch agents from anywhere, they keep working when you close the tab.
|
| 117 |
+
</p>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
<p className="locked-sub">
|
| 121 |
+
This copy is <b>public</b>, so its terminals are disabled: the app has no login of its
|
| 122 |
+
own — a public instance would hand anyone a shell. Your duplicate stays private and unlocks itself.
|
| 123 |
+
</p>
|
| 124 |
|
| 125 |
+
<div className="step">
|
| 126 |
+
<div className="step-head"><span className="step-n mono">1</span><h3>Duplicate this Space</h3></div>
|
| 127 |
+
<p className="locked-sub">
|
| 128 |
+
Open the <b>⋮ menu</b> at the top of this page and choose <b>Duplicate this Space</b>.
|
| 129 |
+
Set the new Space's visibility to <b>Private</b>.
|
| 130 |
+
</p>
|
| 131 |
+
<img src="/install/duplicate-space.png" alt="The Space's ⋮ menu with 'Duplicate this Space' highlighted" />
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<div className="step">
|
| 135 |
+
<div className="step-head"><span className="step-n mono">2</span><h3>Attach a private bucket</h3></div>
|
| 136 |
+
<p className="locked-sub">
|
| 137 |
+
In your new Space: <b>Settings → Storage → Mount a bucket</b>. Create a new bucket,
|
| 138 |
+
keep it <b>Private</b>, set the mount path to <span className="mono">/data</span> and
|
| 139 |
+
access to <b>Read & Write</b>. The Space restarts — and that bucket is where your
|
| 140 |
+
agents' work, logins and history survive rebuilds and sleep.
|
| 141 |
+
</p>
|
| 142 |
+
<img src="/install/mount-bucket.png" alt="The Mount a bucket dialog: private bucket, mount path /data, read & write" />
|
| 143 |
+
</div>
|
| 144 |
|
| 145 |
+
<details className="install-code">
|
| 146 |
+
<summary>Prefer code? Duplicate + bucket in one script</summary>
|
| 147 |
+
<div className="locked-cmd">
|
| 148 |
+
<pre><code>{cmd}</code></pre>
|
| 149 |
+
<button className="locked-copy" onClick={copy} aria-label="Copy setup code" title={copied ? 'Copied' : 'Copy'}>
|
| 150 |
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
| 151 |
+
<rect x="9" y="8" width="10" height="12" rx="2" />
|
| 152 |
+
<path d="M6 16H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
| 153 |
+
</svg>
|
| 154 |
+
</button>
|
| 155 |
+
</div>
|
| 156 |
+
</details>
|
| 157 |
+
|
| 158 |
+
<p className="locked-sub install-relock">
|
| 159 |
+
<b>Is this your Space?</b> Set it back to <b>Private</b> in Settings → Change visibility —
|
| 160 |
+
it unlocks automatically within a minute.
|
| 161 |
+
</p>
|
| 162 |
+
</div>
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
);
|
web/src/components/Overview.tsx
CHANGED
|
@@ -3,11 +3,9 @@ import type { CSSProperties, ReactNode } from 'react';
|
|
| 3 |
import { marked } from 'marked';
|
| 4 |
import * as api from '../api';
|
| 5 |
import type { MetaSession } from '../api';
|
| 6 |
-
import type { Cli, Session, SessionState, Tree } from '../types';
|
| 7 |
import Logo from './Logo';
|
| 8 |
|
| 9 |
-
type Filter = 'all' | 'waiting' | 'working' | 'quiet';
|
| 10 |
-
|
| 11 |
const fmtAgo = (ts: number) => {
|
| 12 |
if (!ts) return '';
|
| 13 |
const m = Math.round((Date.now() - ts) / 60000);
|
|
@@ -20,22 +18,21 @@ const fmtTok = (n = 0) =>
|
|
| 20 |
n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
|
| 21 |
const base = (p: string) => p.split('/').pop() || p;
|
| 22 |
const eligible = (s: Session) => s.cli !== 'shell' && s.cli !== 'files';
|
| 23 |
-
const bucket = (state: SessionState):
|
| 24 |
state === 'working' ? 'working' : state === 'waiting' ? 'waiting' : 'quiet';
|
| 25 |
|
| 26 |
const Caret = () => (
|
| 27 |
<svg className="ov-caret" viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 3.2h6.4L5 7.4z" fill="currentColor" /></svg>
|
| 28 |
);
|
| 29 |
|
| 30 |
-
function Card({ s, color,
|
| 31 |
s: MetaSession;
|
| 32 |
color?: string;
|
| 33 |
-
autoLive?: boolean; // expanded dormant row: open straight into the input
|
| 34 |
onOpen: (sid: string) => void;
|
| 35 |
}) {
|
| 36 |
const d = s.digest;
|
| 37 |
const [draft, setDraft] = useState('');
|
| 38 |
-
const [live, setLive] = useState(
|
| 39 |
const [sending, setSending] = useState(false);
|
| 40 |
const [failed, setFailed] = useState(false);
|
| 41 |
const [sentAt, setSentAt] = useState(0);
|
|
@@ -140,13 +137,16 @@ function Card({ s, color, autoLive, onOpen }: {
|
|
| 140 |
}
|
| 141 |
|
| 142 |
/** Mission control: one reading column — group capsules with their agents as
|
| 143 |
-
* slabs, loose agents as standalone panels
|
| 144 |
-
export default function Overview({ clis, tree, onOpen }: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
const [meta, setMeta] = useState<Record<string, MetaSession> | null>(null);
|
| 146 |
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
| 147 |
const [durs, setDurs] = useState<Record<string, number>>({});
|
| 148 |
-
const [woken, setWoken] = useState<Set<string>>(new Set()); // dormant rows expanded by tap
|
| 149 |
-
const [filter, setFilter] = useState<Filter>('all');
|
| 150 |
|
| 151 |
useEffect(() => {
|
| 152 |
let alive = true;
|
|
@@ -166,11 +166,6 @@ export default function Overview({ clis, tree, onOpen }: { clis: Cli[]; tree: Tr
|
|
| 166 |
if (!meta) return <div className="ov-wrap"><div className="ov-feed"><div className="usage-msg mono">reading traces…<span className="et-cursor" /></div></div></div>;
|
| 167 |
|
| 168 |
const visible = (s: MetaSession) => filter === 'all' || bucket(s.state) === filter;
|
| 169 |
-
// Nothing to say and nothing running → a one-line row until tapped.
|
| 170 |
-
const isDormant = (s: MetaSession) =>
|
| 171 |
-
!woken.has(s.id)
|
| 172 |
-
&& (s.state === 'stopped' || s.state === 'idle')
|
| 173 |
-
&& (!s.digest || (!s.digest.lastPromptText && !s.digest.lastAssistantText));
|
| 174 |
|
| 175 |
// Collapse at constant velocity: duration follows the group's height.
|
| 176 |
const toggleGroup = (gid: string, el: HTMLElement) => {
|
|
@@ -183,28 +178,9 @@ export default function Overview({ clis, tree, onOpen }: { clis: Cli[]; tree: Tr
|
|
| 183 |
const renderItem = (s: Session) => {
|
| 184 |
const m = dataFor(s);
|
| 185 |
if (!visible(m)) return null;
|
| 186 |
-
if (isDormant(m)) {
|
| 187 |
-
return (
|
| 188 |
-
<div
|
| 189 |
-
key={s.id}
|
| 190 |
-
className="ov-panel ov-dormant"
|
| 191 |
-
role="button"
|
| 192 |
-
tabIndex={0}
|
| 193 |
-
onClick={() => setWoken((w) => new Set(w).add(s.id))}
|
| 194 |
-
onKeyDown={(e) => { if (e.key === 'Enter') setWoken((w) => new Set(w).add(s.id)); }}
|
| 195 |
-
>
|
| 196 |
-
<span className={`status ${m.state}`} />
|
| 197 |
-
<Logo cli={s.cli} size={12} tint={colorOf[s.cli]} />
|
| 198 |
-
<span className="ov-name mono">{s.name}</span>
|
| 199 |
-
<span className="ov-ago">· {fmtAgo(Date.parse(s.createdAt) || 0)}</span>
|
| 200 |
-
<span className="spacer" />
|
| 201 |
-
<span className="ov-chev">▸</span>
|
| 202 |
-
</div>
|
| 203 |
-
);
|
| 204 |
-
}
|
| 205 |
return (
|
| 206 |
<div key={s.id} className="ov-panel">
|
| 207 |
-
<Card s={m} color={colorOf[s.cli]}
|
| 208 |
</div>
|
| 209 |
);
|
| 210 |
};
|
|
@@ -246,18 +222,6 @@ export default function Overview({ clis, tree, onOpen }: { clis: Cli[]; tree: Tr
|
|
| 246 |
return (
|
| 247 |
<div className="ov-wrap">
|
| 248 |
<div className="ov-feed">
|
| 249 |
-
<div className="ov-headrow">
|
| 250 |
-
<h1 className="ov-title">Overview</h1>
|
| 251 |
-
<span className="spacer" />
|
| 252 |
-
<div className="ov-filters">
|
| 253 |
-
{(['all', 'waiting', 'working', 'quiet'] as Filter[]).map((f) => (
|
| 254 |
-
<button key={f} className={`ov-fchip${filter === f ? ' on' : ''}`} onClick={() => setFilter(f)}>
|
| 255 |
-
{f !== 'all' && <span className={`status ${f === 'quiet' ? 'idle' : f}`} />}
|
| 256 |
-
{f === 'waiting' ? 'your turn' : f}
|
| 257 |
-
</button>
|
| 258 |
-
))}
|
| 259 |
-
</div>
|
| 260 |
-
</div>
|
| 261 |
{blocks.length === 0 && <div className="usage-msg mono">{filter === 'all' ? 'no agents yet — shells and file panes don’t appear here.' : 'nothing in this state.'}</div>}
|
| 262 |
{blocks}
|
| 263 |
</div>
|
|
|
|
| 3 |
import { marked } from 'marked';
|
| 4 |
import * as api from '../api';
|
| 5 |
import type { MetaSession } from '../api';
|
| 6 |
+
import type { Cli, OverviewFilter, Session, SessionState, Tree } from '../types';
|
| 7 |
import Logo from './Logo';
|
| 8 |
|
|
|
|
|
|
|
| 9 |
const fmtAgo = (ts: number) => {
|
| 10 |
if (!ts) return '';
|
| 11 |
const m = Math.round((Date.now() - ts) / 60000);
|
|
|
|
| 18 |
n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
|
| 19 |
const base = (p: string) => p.split('/').pop() || p;
|
| 20 |
const eligible = (s: Session) => s.cli !== 'shell' && s.cli !== 'files';
|
| 21 |
+
const bucket = (state: SessionState): OverviewFilter =>
|
| 22 |
state === 'working' ? 'working' : state === 'waiting' ? 'waiting' : 'quiet';
|
| 23 |
|
| 24 |
const Caret = () => (
|
| 25 |
<svg className="ov-caret" viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 3.2h6.4L5 7.4z" fill="currentColor" /></svg>
|
| 26 |
);
|
| 27 |
|
| 28 |
+
function Card({ s, color, onOpen }: {
|
| 29 |
s: MetaSession;
|
| 30 |
color?: string;
|
|
|
|
| 31 |
onOpen: (sid: string) => void;
|
| 32 |
}) {
|
| 33 |
const d = s.digest;
|
| 34 |
const [draft, setDraft] = useState('');
|
| 35 |
+
const [live, setLive] = useState(false);
|
| 36 |
const [sending, setSending] = useState(false);
|
| 37 |
const [failed, setFailed] = useState(false);
|
| 38 |
const [sentAt, setSentAt] = useState(0);
|
|
|
|
| 137 |
}
|
| 138 |
|
| 139 |
/** Mission control: one reading column — group capsules with their agents as
|
| 140 |
+
* slabs, loose agents as standalone panels. */
|
| 141 |
+
export default function Overview({ clis, tree, filter, onOpen }: {
|
| 142 |
+
clis: Cli[];
|
| 143 |
+
tree: Tree;
|
| 144 |
+
filter: OverviewFilter; // controlled by the bottom bar in App
|
| 145 |
+
onOpen: (sid: string) => void;
|
| 146 |
+
}) {
|
| 147 |
const [meta, setMeta] = useState<Record<string, MetaSession> | null>(null);
|
| 148 |
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
| 149 |
const [durs, setDurs] = useState<Record<string, number>>({});
|
|
|
|
|
|
|
| 150 |
|
| 151 |
useEffect(() => {
|
| 152 |
let alive = true;
|
|
|
|
| 166 |
if (!meta) return <div className="ov-wrap"><div className="ov-feed"><div className="usage-msg mono">reading traces…<span className="et-cursor" /></div></div></div>;
|
| 167 |
|
| 168 |
const visible = (s: MetaSession) => filter === 'all' || bucket(s.state) === filter;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
// Collapse at constant velocity: duration follows the group's height.
|
| 171 |
const toggleGroup = (gid: string, el: HTMLElement) => {
|
|
|
|
| 178 |
const renderItem = (s: Session) => {
|
| 179 |
const m = dataFor(s);
|
| 180 |
if (!visible(m)) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
return (
|
| 182 |
<div key={s.id} className="ov-panel">
|
| 183 |
+
<Card s={m} color={colorOf[s.cli]} onOpen={onOpen} />
|
| 184 |
</div>
|
| 185 |
);
|
| 186 |
};
|
|
|
|
| 222 |
return (
|
| 223 |
<div className="ov-wrap">
|
| 224 |
<div className="ov-feed">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
{blocks.length === 0 && <div className="usage-msg mono">{filter === 'all' ? 'no agents yet — shells and file panes don’t appear here.' : 'nothing in this state.'}</div>}
|
| 226 |
{blocks}
|
| 227 |
</div>
|
web/src/styles.css
CHANGED
|
@@ -188,14 +188,7 @@ body {
|
|
| 188 |
/* ---- Overview: one reading column of capsules (design mock v4.12) ---- */
|
| 189 |
.ov-wrap { height: 100%; overflow-y: auto; }
|
| 190 |
.ov-feed { max-width: 680px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; padding: 2px 2px 40px; }
|
| 191 |
-
.ov-
|
| 192 |
-
.ov-headrow .spacer { flex: 1; }
|
| 193 |
-
.ov-title { font-size: 14px; margin: 0; letter-spacing: -0.01em; }
|
| 194 |
-
.ov-filters { display: inline-flex; gap: 4px; }
|
| 195 |
-
.ov-fchip { display: inline-flex; align-items: center; gap: 5px; background: var(--panel); border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; font: inherit; font-size: 11.5px; color: var(--muted); cursor: pointer; }
|
| 196 |
-
.ov-fchip .status { width: 6px; height: 6px; }
|
| 197 |
-
.ov-fchip:hover { border-color: var(--border-strong); color: var(--text); }
|
| 198 |
-
.ov-fchip.on { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, var(--panel)); }
|
| 199 |
|
| 200 |
.ov-panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); }
|
| 201 |
|
|
@@ -259,11 +252,6 @@ body {
|
|
| 259 |
.ov-hint { font-size: 10.5px; color: var(--muted); flex: none; }
|
| 260 |
.ov-note { font-size: 11px; color: var(--danger); }
|
| 261 |
|
| 262 |
-
/* dormant agents: one-line panels until tapped */
|
| 263 |
-
.ov-dormant { display: flex; align-items: center; gap: 8px; padding: 8px 14px; cursor: pointer; }
|
| 264 |
-
.ov-dormant:hover { border-color: var(--border-strong); }
|
| 265 |
-
.ov-chev { color: var(--muted); font-size: 10px; }
|
| 266 |
-
|
| 267 |
/* tree */
|
| 268 |
.tree { flex: 1; overflow-y: auto; padding: 6px 8px 10px; }
|
| 269 |
.empty-hint { color: var(--muted); font-size: 12px; padding: 12px 10px; line-height: 1.5; }
|
|
@@ -338,14 +326,26 @@ body {
|
|
| 338 |
.pane-head.draggable:active { cursor: grabbing; }
|
| 339 |
.slot { position: relative; min-width: 0; min-height: 0; border-radius: var(--r-xl); overflow: hidden; border: 1px solid var(--border); background: var(--term-bg); display: flex; flex-direction: column; }
|
| 340 |
|
| 341 |
-
/* public-space locked screen */
|
| 342 |
-
.locked
|
| 343 |
-
.locked-
|
| 344 |
-
.
|
| 345 |
-
.
|
| 346 |
-
.
|
| 347 |
-
.
|
| 348 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
.locked-cmd { position: relative; margin-top: 8px; }
|
| 350 |
.locked-cmd pre { margin: 0; overflow-x: hidden; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 42px 14px 14px; font-size: 12px; line-height: 1.5; }
|
| 351 |
.locked-copy { position: absolute; top: 10px; right: 10px; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); color: var(--muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
|
@@ -599,6 +599,8 @@ body {
|
|
| 599 |
/* 16px inputs stop iOS zoom-on-focus */
|
| 600 |
.widget input, .widget select, .row .rename, .secret-desc, .fp-input, .pane-head .ph-title-input, .ov-live input { font-size: 16px; }
|
| 601 |
.ov-headrow { flex-wrap: wrap; }
|
|
|
|
|
|
|
| 602 |
/* settings stacks vertically */
|
| 603 |
.app.settings { flex-direction: column; }
|
| 604 |
.app.settings .sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
|
|
|
|
| 188 |
/* ---- Overview: one reading column of capsules (design mock v4.12) ---- */
|
| 189 |
.ov-wrap { height: 100%; overflow-y: auto; }
|
| 190 |
.ov-feed { max-width: 680px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; padding: 2px 2px 40px; }
|
| 191 |
+
.ov-seg button { padding: 3px 11px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
.ov-panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); }
|
| 194 |
|
|
|
|
| 252 |
.ov-hint { font-size: 10.5px; color: var(--muted); flex: none; }
|
| 253 |
.ov-note { font-size: 11px; color: var(--danger); }
|
| 254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
/* tree */
|
| 256 |
.tree { flex: 1; overflow-y: auto; padding: 6px 8px 10px; }
|
| 257 |
.empty-hint { color: var(--muted); font-size: 12px; padding: 12px 10px; line-height: 1.5; }
|
|
|
|
| 326 |
.pane-head.draggable:active { cursor: grabbing; }
|
| 327 |
.slot { position: relative; min-width: 0; min-height: 0; border-radius: var(--r-xl); overflow: hidden; border: 1px solid var(--border); background: var(--term-bg); display: flex; flex-direction: column; }
|
| 328 |
|
| 329 |
+
/* public-space locked screen: app shell with a frozen sidebar + install guide */
|
| 330 |
+
.locked-app .mock-side { pointer-events: none; user-select: none; opacity: 0.75; }
|
| 331 |
+
.locked-main { overflow-y: auto; }
|
| 332 |
+
.install { max-width: 620px; margin: 0 auto; padding: 26px 6px 60px; display: flex; flex-direction: column; gap: 18px; }
|
| 333 |
+
.install-head { display: flex; gap: 14px; align-items: flex-start; }
|
| 334 |
+
.install-lock { flex: none; width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); border-radius: var(--r-lg); margin-top: 2px; }
|
| 335 |
+
.install-lock svg { width: 20px; height: 20px; color: var(--accent); }
|
| 336 |
+
.install h1 { margin: 0 0 6px; font-size: 19px; letter-spacing: -0.01em; text-wrap: balance; }
|
| 337 |
+
.locked-lead { color: var(--text); font-size: 13.5px; line-height: 1.55; margin: 0; }
|
| 338 |
+
.locked-sub { color: var(--muted); font-size: 13px; line-height: 1.55; margin: 0; }
|
| 339 |
+
.locked-sub b { color: var(--text); }
|
| 340 |
+
.step { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; }
|
| 341 |
+
.step-head { display: flex; align-items: center; gap: 10px; }
|
| 342 |
+
.step-head h3 { margin: 0; font-size: 14px; }
|
| 343 |
+
.step-n { flex: none; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); color: var(--accent); border-radius: 50%; font-size: 11.5px; font-weight: 600; }
|
| 344 |
+
.step img { width: 100%; max-width: 480px; border: 1px solid var(--border); border-radius: var(--r-lg); display: block; }
|
| 345 |
+
.install-code summary { cursor: pointer; color: var(--muted); font-size: 13px; padding: 2px 0; }
|
| 346 |
+
.install-code summary:hover { color: var(--text); }
|
| 347 |
+
.install-code[open] summary { margin-bottom: 8px; }
|
| 348 |
+
.install-relock { border-top: 1px solid var(--border); padding-top: 14px; }
|
| 349 |
.locked-cmd { position: relative; margin-top: 8px; }
|
| 350 |
.locked-cmd pre { margin: 0; overflow-x: hidden; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 42px 14px 14px; font-size: 12px; line-height: 1.5; }
|
| 351 |
.locked-copy { position: absolute; top: 10px; right: 10px; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); color: var(--muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
|
|
|
| 599 |
/* 16px inputs stop iOS zoom-on-focus */
|
| 600 |
.widget input, .widget select, .row .rename, .secret-desc, .fp-input, .pane-head .ph-title-input, .ov-live input { font-size: 16px; }
|
| 601 |
.ov-headrow { flex-wrap: wrap; }
|
| 602 |
+
/* install page: the decorative sidebar makes no sense on a phone */
|
| 603 |
+
.locked-app .mock-side { display: none; }
|
| 604 |
/* settings stacks vertically */
|
| 605 |
.app.settings { flex-direction: column; }
|
| 606 |
.app.settings .sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
|
web/src/types.ts
CHANGED
|
@@ -46,6 +46,8 @@ export const STATE_LABEL: Record<SessionState, string> = {
|
|
| 46 |
stopped: 'stopped',
|
| 47 |
};
|
| 48 |
|
|
|
|
|
|
|
| 49 |
export type MoveTarget =
|
| 50 |
| { kind: 'into'; groupId: string }
|
| 51 |
| { kind: 'pair'; sessionId: string }
|
|
|
|
| 46 |
stopped: 'stopped',
|
| 47 |
};
|
| 48 |
|
| 49 |
+
export type OverviewFilter = 'all' | 'waiting' | 'working' | 'quiet';
|
| 50 |
+
|
| 51 |
export type MoveTarget =
|
| 52 |
| { kind: 'into'; groupId: string }
|
| 53 |
| { kind: 'pair'; sessionId: string }
|