lvwerra HF Staff commited on
Commit
b53cd8f
·
verified ·
1 Parent(s): 7c91582

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 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

  • SHA256: b87893576e2c3c499f1d065479397a64764ce385ac3229b14da22b56b3b0e4f4
  • Pointer size: 131 Bytes
  • Size of remote file: 220 kB
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
- // Shown when the server reports the Space is public. The terminal backend is
5
- // disabled server-side; this explains how to run a private copy or relock.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div className="locked-card">
35
- <div className="locked-emoji"><LockGlyph /></div>
36
- <h1>Agent Manager</h1>
37
- <p className="locked-lead">
38
- This Space is <b>public</b>, so the terminal manager is disabled. It has
39
- no authentication — a public instance would hand anyone a shell and your
40
- logged-in agents.
41
- </p>
 
 
 
 
 
 
 
 
 
42
 
43
- <h3>Run your own private copy</h3>
44
- <p className="locked-sub">
45
- Create a private Storage Bucket mounted at <b>/data</b>, then duplicate this Space:
46
- </p>
47
- <div className="locked-cmd">
48
- <pre><code>{cmd}</code></pre>
49
- <button className="locked-copy" onClick={copy} aria-label="Copy setup code" title={copied ? 'Copied' : 'Copy'}>
50
- <svg viewBox="0 0 24 24" aria-hidden="true">
51
- <rect x="9" y="8" width="10" height="12" rx="2" />
52
- <path d="M6 16H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
53
- </svg>
54
- </button>
55
- </div>
 
 
 
 
 
 
56
 
57
- <h3>Is this your Space?</h3>
58
- <p className="locked-sub">
59
- Set it to <b>Private</b> in <b>Settings → Change visibility</b>. It unlocks
60
- automatically within a minute.
61
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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): Filter =>
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, autoLive, onOpen }: {
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(!!autoLive);
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, dormant agents as one-liners. */
144
- export default function Overview({ clis, tree, onOpen }: { clis: Cli[]; tree: Tree; onOpen: (sid: string) => void }) {
 
 
 
 
 
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]} autoLive={woken.has(s.id)} onOpen={onOpen} />
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-headrow { display: flex; align-items: center; gap: 10px; padding: 0 2px; }
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 { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--bg); }
343
- .locked-card { max-width: 920px; width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 32px 34px; box-shadow: 0 12px 40px rgba(0,0,0,0.22); }
344
- .locked-emoji svg { width: 36px; height: 36px; color: var(--accent); }
345
- .locked-card h1 { margin: 8px 0 4px; font-size: 22px; }
346
- .locked-card h3 { margin: 22px 0 6px; font-size: 14px; }
347
- .locked-lead { color: var(--text); font-size: 14px; line-height: 1.55; }
348
- .locked-sub { color: var(--muted); font-size: 13px; line-height: 1.5; }
 
 
 
 
 
 
 
 
 
 
 
 
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 }