lvwerra HF Staff Claude Fable 5 commited on
Commit
585ab8a
·
1 Parent(s): a5a7b57

Overview tiles + conversation window; archive inactive sessions

Browse files

The Overview's default is now a compact tile grid: status dot, one-line
prompt, and a state line (spinning "running", "✓ done", "stopped") per
session, groups wrapped in a fine fieldset-style outline, and an accent
ring only on your-turn tiles fresher than a day. Clicking a tile opens
a conversation window over the grid — the full card (turn arrows, live
running state, reply) plus open-pane and close. The old list stays one
toggle away (icon segment next to the filters, both centered in the
bottom bar); the reply line everywhere is now a textarea where
shift+enter inserts a newline.

Sessions with no activity beyond a configurable window (Settings: 1
week / 1 month / never, default 1 month) are archived: hidden from the
sidebar and overview, nothing stored or deleted — flipping the setting
instantly (un)archives, working sessions and shells/files panes are
exempt. The sidebar legend (now working / idle / stopped) gains a quiet
"archived" checkbox to reveal them greyed out, and the tree notes "not
showing N archived sessions" at its foot.

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

server/src/index.js CHANGED
@@ -329,6 +329,11 @@ function loadAmConfig() {
329
  // 0 = always ask first.
330
  askAboveUsd: Number.isFinite(saved.jobs?.askAboveUsd) ? Math.max(0, saved.jobs.askAboveUsd) : 0,
331
  },
 
 
 
 
 
332
  };
333
  }
334
  app.get('/api/config', (_req, res) => res.json({ ...loadAmConfig(), defaultArtifactsSpace: defaultArtifactsSpace() }));
@@ -341,6 +346,7 @@ app.put('/api/config', (req, res) => {
341
  visibility: b.artifacts?.visibility === 'public' ? 'public' : 'private',
342
  },
343
  jobs: { askAboveUsd: Math.max(0, Number(b.jobs?.askAboveUsd) || 0) },
 
344
  };
345
  try { fs.writeFileSync(AM_CONFIG_FILE, JSON.stringify(cfg, null, 2)); } catch {}
346
  generateEnvSkill(loadSecretNotes());
 
329
  // 0 = always ask first.
330
  askAboveUsd: Number.isFinite(saved.jobs?.askAboveUsd) ? Math.max(0, saved.jobs.askAboveUsd) : 0,
331
  },
332
+ // Sessions with no activity beyond this window are hidden from the sidebar
333
+ // and overview (derived client-side; nothing is deleted).
334
+ archive: {
335
+ after: ['week', 'month', 'never'].includes(saved.archive?.after) ? saved.archive.after : 'month',
336
+ },
337
  };
338
  }
339
  app.get('/api/config', (_req, res) => res.json({ ...loadAmConfig(), defaultArtifactsSpace: defaultArtifactsSpace() }));
 
346
  visibility: b.artifacts?.visibility === 'public' ? 'public' : 'private',
347
  },
348
  jobs: { askAboveUsd: Math.max(0, Number(b.jobs?.askAboveUsd) || 0) },
349
+ archive: { after: ['week', 'month', 'never'].includes(b.archive?.after) ? b.archive.after : 'month' },
350
  };
351
  try { fs.writeFileSync(AM_CONFIG_FILE, JSON.stringify(cfg, null, 2)); } catch {}
352
  generateEnvSkill(loadSecretNotes());
web/src/App.tsx CHANGED
@@ -11,6 +11,7 @@ import Locked from './components/Locked';
11
  import Welcome from './components/Welcome';
12
  import * as api from './api';
13
  import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
 
14
 
15
  // Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
16
  function useIsMobile() {
@@ -57,6 +58,15 @@ export default function App() {
57
  // Transient error toast for failed mutations (create/delete/move/…).
58
  const [toast, setToast] = useState<string | null>(null);
59
  const showErr = (msg: string) => (e: unknown) => { console.error(msg, e); setToast(msg); window.setTimeout(() => setToast(null), 4000); };
 
 
 
 
 
 
 
 
 
60
  const [zoom, setZoom] = useState<number>(() => {
61
  const z = parseInt(localStorage.getItem('am-zoom') || '100', 10);
62
  return Number.isFinite(z) ? z : 100;
@@ -152,6 +162,25 @@ export default function App() {
152
  return () => { alive = false; clearInterval(t); };
153
  }, []);
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  const cliMap = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c])), [clis]);
156
  const sessById = useMemo(() => Object.fromEntries(tree.sessions.map((s) => [s.id, s])), [tree.sessions]);
157
  const groupById = useMemo(() => Object.fromEntries(tree.groups.map((g) => [g.id, g])), [tree.groups]);
@@ -438,6 +467,9 @@ export default function App() {
438
  theme={theme}
439
  onToggleTheme={toggleTheme}
440
  onQuickStart={quickStart}
 
 
 
441
  />
442
 
443
  <div className="main">
@@ -464,6 +496,9 @@ export default function App() {
464
  clis={clis}
465
  tree={tree}
466
  filter={ovFilter}
 
 
 
467
  onOpen={(sid) => {
468
  const g = tree.groups.find((x) => x.sessionIds.includes(sid));
469
  openSession(sid, g?.id);
@@ -496,7 +531,7 @@ export default function App() {
496
  )}
497
  </div>
498
  {activeRef === 'overview' && (
499
- <div className="zoombar">
500
  <div className="seg ov-seg">
501
  {(['all', 'waiting', 'working', 'quiet'] as OverviewFilter[]).map((f) => (
502
  <button key={f} className={ovFilter === f ? 'on' : ''} onClick={() => setOvFilter(f)}>
@@ -504,7 +539,10 @@ export default function App() {
504
  </button>
505
  ))}
506
  </div>
507
- <span className="spacer" />
 
 
 
508
  </div>
509
  )}
510
  {showZoom && (
 
11
  import Welcome from './components/Welcome';
12
  import * as api from './api';
13
  import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
14
+ import { GridGlyph, ListGlyph } from './components/icons';
15
 
16
  // Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
17
  function useIsMobile() {
 
58
  // Transient error toast for failed mutations (create/delete/move/…).
59
  const [toast, setToast] = useState<string | null>(null);
60
  const showErr = (msg: string) => (e: unknown) => { console.error(msg, e); setToast(msg); window.setTimeout(() => setToast(null), 4000); };
61
+ // Overview presentation: tiles (default) or the classic list.
62
+ const [ovView, setOvViewRaw] = useState<'tiles' | 'list'>(() =>
63
+ (localStorage.getItem('am-ov-view') === 'list' ? 'list' : 'tiles'));
64
+ const setOvView = (v: 'tiles' | 'list') => { setOvViewRaw(v); localStorage.setItem('am-ov-view', v); };
65
+ // Archiving: sessions quiet for longer than the configured window are hidden
66
+ // from the sidebar and overview unless "archived" is checked. Derived, never
67
+ // stored — flipping the setting instantly (un)archives.
68
+ const [showArchived, setShowArchived] = useState(false);
69
+ const [archiveAfter, setArchiveAfter] = useState<'week' | 'month' | 'never'>('month');
70
  const [zoom, setZoom] = useState<number>(() => {
71
  const z = parseInt(localStorage.getItem('am-zoom') || '100', 10);
72
  return Number.isFinite(z) ? z : 100;
 
162
  return () => { alive = false; clearInterval(t); };
163
  }, []);
164
 
165
+ // Archive threshold from the operator config; refresh when settings closes
166
+ // (that's where it's edited).
167
+ useEffect(() => {
168
+ if (settingsOpen) return;
169
+ api.getConfig().then((c) => setArchiveAfter(c.archive?.after ?? 'month')).catch(() => {});
170
+ }, [settingsOpen]);
171
+ const archivedIds = useMemo(() => {
172
+ const out = new Set<string>();
173
+ if (archiveAfter === 'never') return out;
174
+ const cut = Date.now() - (archiveAfter === 'week' ? 7 : 30) * 864e5;
175
+ for (const s of tree.sessions) {
176
+ // Shells and file panes have no trace clock — never archive them.
177
+ if (s.cli === 'shell' || s.cli === 'files' || s.state === 'working') continue;
178
+ const last = ages[s.id] || Date.parse(s.createdAt) || 0;
179
+ if (last && last < cut) out.add(s.id);
180
+ }
181
+ return out;
182
+ }, [tree.sessions, ages, archiveAfter]);
183
+
184
  const cliMap = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c])), [clis]);
185
  const sessById = useMemo(() => Object.fromEntries(tree.sessions.map((s) => [s.id, s])), [tree.sessions]);
186
  const groupById = useMemo(() => Object.fromEntries(tree.groups.map((g) => [g.id, g])), [tree.groups]);
 
467
  theme={theme}
468
  onToggleTheme={toggleTheme}
469
  onQuickStart={quickStart}
470
+ archived={archivedIds}
471
+ showArchived={showArchived}
472
+ onToggleArchived={() => setShowArchived((v) => !v)}
473
  />
474
 
475
  <div className="main">
 
496
  clis={clis}
497
  tree={tree}
498
  filter={ovFilter}
499
+ view={ovView}
500
+ archived={archivedIds}
501
+ showArchived={showArchived}
502
  onOpen={(sid) => {
503
  const g = tree.groups.find((x) => x.sessionIds.includes(sid));
504
  openSession(sid, g?.id);
 
531
  )}
532
  </div>
533
  {activeRef === 'overview' && (
534
+ <div className="zoombar ov-bar">
535
  <div className="seg ov-seg">
536
  {(['all', 'waiting', 'working', 'quiet'] as OverviewFilter[]).map((f) => (
537
  <button key={f} className={ovFilter === f ? 'on' : ''} onClick={() => setOvFilter(f)}>
 
539
  </button>
540
  ))}
541
  </div>
542
+ <div className="seg ov-seg ov-viewseg">
543
+ <button className={ovView === 'tiles' ? 'on' : ''} title="Tiles" onClick={() => setOvView('tiles')}><GridGlyph /></button>
544
+ <button className={ovView === 'list' ? 'on' : ''} title="List" onClick={() => setOvView('list')}><ListGlyph /></button>
545
+ </div>
546
  </div>
547
  )}
548
  {showZoom && (
web/src/api.ts CHANGED
@@ -60,6 +60,7 @@ export const relaunchSpace = (): Promise<{ ok: boolean; reason?: string }> =>
60
  export interface AmConfig {
61
  artifacts: { enabled: boolean; space: string; visibility: 'public' | 'private' };
62
  jobs: { askAboveUsd: number };
 
63
  defaultArtifactsSpace?: string;
64
  }
65
  export const getConfig = (): Promise<AmConfig> => fetch('/api/config').then(json);
 
60
  export interface AmConfig {
61
  artifacts: { enabled: boolean; space: string; visibility: 'public' | 'private' };
62
  jobs: { askAboveUsd: number };
63
+ archive: { after: 'week' | 'month' | 'never' };
64
  defaultArtifactsSpace?: string;
65
  }
66
  export const getConfig = (): Promise<AmConfig> => fetch('/api/config').then(json);
web/src/components/Overview.tsx CHANGED
@@ -25,10 +25,11 @@ 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('');
@@ -37,7 +38,7 @@ function Card({ s, color, onOpen }: {
37
  const [sentAt, setSentAt] = useState(0);
38
  const [expanded, setExpanded] = useState(false);
39
  const [histIdx, setHistIdx] = useState(0); // 0 = live view, n = n-th exchange back
40
- const inputRef = useRef<HTMLInputElement>(null);
41
 
42
  // After you send (or when the transcript shows a prompt newer than the last
43
  // answer), the old answer is stale — a spinner takes its place.
@@ -62,7 +63,7 @@ function Card({ s, color, onOpen }: {
62
  setDraft('');
63
  setSentAt(Date.now());
64
  setHistIdx(0);
65
- inputRef.current?.blur();
66
  } catch {
67
  setFailed(true);
68
  setTimeout(() => setFailed(false), 4000);
@@ -93,6 +94,7 @@ function Card({ s, color, onOpen }: {
93
  {ago && <span className="ov-ago">· {ago}</span>}
94
  <span className="spacer" />
95
  <span className="ov-go">open ↗</span>
 
96
  </div>
97
 
98
  {promptText ? (
@@ -140,8 +142,9 @@ function Card({ s, color, onOpen }: {
140
 
141
  <div className="ov-live">
142
  <span className="ov-p mono">❯</span>
143
- <input
144
  ref={inputRef}
 
145
  value={draft}
146
  disabled={sending}
147
  placeholder={sending ? 'sending…' : 'reply…'}
@@ -149,34 +152,73 @@ function Card({ s, color, onOpen }: {
149
  autoCorrect="off"
150
  autoCapitalize="off"
151
  spellCheck={false}
152
- enterKeyHint="send"
153
- onChange={(e) => setDraft(e.target.value)}
154
  // iOS doesn't resize the layout for the keyboard — scroll the
155
  // input into view once the keyboard has animated in.
156
  onFocus={(e) => { const el = e.currentTarget; setTimeout(() => el.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300); }}
157
  onKeyDown={(e) => {
158
- if (e.key === 'Enter') send();
159
  if (e.key === 'Escape') { setDraft(''); inputRef.current?.blur(); }
160
  }}
161
  />
162
- {draft.trim() && <span className="ov-hint">↵ send</span>}
163
  </div>
164
  {failed && <div className="ov-note">failed to reach the agent</div>}
165
  </div>
166
  );
167
  }
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  /** Mission control: one reading column — group capsules with their agents as
170
  * slabs, loose agents as standalone panels. */
171
- export default function Overview({ clis, tree, filter, onOpen }: {
172
  clis: Cli[];
173
  tree: Tree;
174
  filter: OverviewFilter; // controlled by the bottom bar in App
 
 
 
175
  onOpen: (sid: string) => void;
176
  }) {
177
  const [meta, setMeta] = useState<Record<string, MetaSession> | null>(null);
178
  const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
179
  const [durs, setDurs] = useState<Record<string, number>>({});
 
 
 
 
 
 
 
180
 
181
  useEffect(() => {
182
  let alive = true;
@@ -195,7 +237,8 @@ export default function Overview({ clis, tree, filter, onOpen }: {
195
 
196
  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>;
197
 
198
- const visible = (s: MetaSession) => filter === 'all' || bucket(s.state) === filter;
 
199
 
200
  // Collapse at constant velocity: duration follows the group's height.
201
  const toggleGroup = (gid: string, el: HTMLElement) => {
@@ -215,6 +258,60 @@ export default function Overview({ clis, tree, filter, onOpen }: {
215
  );
216
  };
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  const blocks: ReactNode[] = [];
219
  for (const ref of tree.order) {
220
  if (ref.startsWith('s:')) {
 
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, onClose }: {
29
  s: MetaSession;
30
  color?: string;
31
  onOpen: (sid: string) => void;
32
+ onClose?: () => void; // present when the card lives in the conversation window
33
  }) {
34
  const d = s.digest;
35
  const [draft, setDraft] = useState('');
 
38
  const [sentAt, setSentAt] = useState(0);
39
  const [expanded, setExpanded] = useState(false);
40
  const [histIdx, setHistIdx] = useState(0); // 0 = live view, n = n-th exchange back
41
+ const inputRef = useRef<HTMLTextAreaElement>(null);
42
 
43
  // After you send (or when the transcript shows a prompt newer than the last
44
  // answer), the old answer is stale — a spinner takes its place.
 
63
  setDraft('');
64
  setSentAt(Date.now());
65
  setHistIdx(0);
66
+ if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.blur(); }
67
  } catch {
68
  setFailed(true);
69
  setTimeout(() => setFailed(false), 4000);
 
94
  {ago && <span className="ov-ago">· {ago}</span>}
95
  <span className="spacer" />
96
  <span className="ov-go">open ↗</span>
97
+ {onClose && <button className="ov-x" onClick={(e) => { e.stopPropagation(); onClose(); }} title="Close">✕</button>}
98
  </div>
99
 
100
  {promptText ? (
 
142
 
143
  <div className="ov-live">
144
  <span className="ov-p mono">❯</span>
145
+ <textarea
146
  ref={inputRef}
147
+ rows={1}
148
  value={draft}
149
  disabled={sending}
150
  placeholder={sending ? 'sending…' : 'reply…'}
 
152
  autoCorrect="off"
153
  autoCapitalize="off"
154
  spellCheck={false}
155
+ onChange={(e) => { setDraft(e.target.value); e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`; }}
 
156
  // iOS doesn't resize the layout for the keyboard — scroll the
157
  // input into view once the keyboard has animated in.
158
  onFocus={(e) => { const el = e.currentTarget; setTimeout(() => el.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300); }}
159
  onKeyDown={(e) => {
160
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
161
  if (e.key === 'Escape') { setDraft(''); inputRef.current?.blur(); }
162
  }}
163
  />
164
+ {draft.trim() && <span className="ov-hint">↵ send · ⇧↵ newline</span>}
165
  </div>
166
  {failed && <div className="ov-note">failed to reach the agent</div>}
167
  </div>
168
  );
169
  }
170
 
171
+ /** Compact tile: status + prompt + state; click opens the conversation window. */
172
+ function Tile({ s, color, dim, onOpen }: { s: MetaSession; color?: string; dim?: boolean; onOpen: () => void }) {
173
+ const d = s.digest;
174
+ const running = !!d?.running || s.state === 'working';
175
+ const last = Math.max(d?.lastAssistantTs || 0, d?.lastPromptTs || 0) || Date.parse(s.createdAt) || 0;
176
+ // ring = waiting on you AND recent — a fleet where everything is "waiting
177
+ // since last week" shouldn't glow everywhere
178
+ const fresh = s.state === 'waiting' && Date.now() - last < 24 * 3600e3;
179
+ return (
180
+ <div className={`ovt-tile${fresh ? ' attn' : ''}${dim ? ' archived' : ''}`} onClick={onOpen}>
181
+ <div className="ovt-head">
182
+ <span className={`status ${s.state}`} />
183
+ <Logo cli={s.cli} size={12} tint={color} />
184
+ <span className="ovt-name mono">{s.name}</span>
185
+ <span className="ovt-ago">{fmtAgo(last)}</span>
186
+ </div>
187
+ {d?.lastPromptText
188
+ ? <div className="ovt-prompt" title={d.lastPromptText}>{d.lastPromptText}</div>
189
+ : <div className="ovt-prompt none">no prompt yet</div>}
190
+ {running
191
+ ? <div className="ovt-state running mono">running</div>
192
+ : s.state === 'stopped'
193
+ ? <div className="ovt-state stopped mono">stopped</div>
194
+ : d?.lastAssistantText
195
+ ? <div className="ovt-state done mono">✓ done</div>
196
+ : <div className="ovt-state idle mono">idle</div>}
197
+ </div>
198
+ );
199
+ }
200
+
201
  /** Mission control: one reading column — group capsules with their agents as
202
  * slabs, loose agents as standalone panels. */
203
+ export default function Overview({ clis, tree, filter, view, archived, showArchived, onOpen }: {
204
  clis: Cli[];
205
  tree: Tree;
206
  filter: OverviewFilter; // controlled by the bottom bar in App
207
+ view: 'tiles' | 'list'; // controlled by the bottom bar in App
208
+ archived: Set<string>;
209
+ showArchived: boolean;
210
  onOpen: (sid: string) => void;
211
  }) {
212
  const [meta, setMeta] = useState<Record<string, MetaSession> | null>(null);
213
  const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
214
  const [durs, setDurs] = useState<Record<string, number>>({});
215
+ const [openId, setOpenId] = useState<string | null>(null); // conversation window
216
+ useEffect(() => {
217
+ if (!openId) return;
218
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpenId(null); };
219
+ document.addEventListener('keydown', onKey);
220
+ return () => document.removeEventListener('keydown', onKey);
221
+ }, [openId]);
222
 
223
  useEffect(() => {
224
  let alive = true;
 
237
 
238
  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>;
239
 
240
+ const visible = (s: MetaSession) =>
241
+ (filter === 'all' || bucket(s.state) === filter) && (showArchived || !archived.has(s.id));
242
 
243
  // Collapse at constant velocity: duration follows the group's height.
244
  const toggleGroup = (gid: string, el: HTMLElement) => {
 
258
  );
259
  };
260
 
261
+ // ---- tile view: loose sessions pack into grids, groups get a fine outline ----
262
+ const tileFor = (s: Session) => {
263
+ const m = dataFor(s);
264
+ if (!visible(m)) return null;
265
+ return <Tile key={s.id} s={m} color={colorOf[s.cli]} dim={archived.has(s.id)} onOpen={() => setOpenId(s.id)} />;
266
+ };
267
+ const tileBlocks: ReactNode[] = [];
268
+ let looseTiles: ReactNode[] = [];
269
+ const flushLoose = () => {
270
+ if (looseTiles.length) tileBlocks.push(<div className="ovt-grid" key={`loose-${tileBlocks.length}`}>{looseTiles}</div>);
271
+ looseTiles = [];
272
+ };
273
+ for (const ref of tree.order) {
274
+ if (ref.startsWith('s:')) {
275
+ const s = sessById[ref.slice(2)];
276
+ if (s && eligible(s)) { const t = tileFor(s); if (t) looseTiles.push(t); }
277
+ } else {
278
+ const g = groupById[ref.slice(2)];
279
+ if (!g) continue;
280
+ const members = g.sessionIds.map((id) => sessById[id]).filter(Boolean).filter(eligible) as Session[];
281
+ const shown = members.map(tileFor).filter(Boolean);
282
+ if (!shown.length) continue;
283
+ flushLoose();
284
+ tileBlocks.push(
285
+ <div key={g.id} className="ovt-group">
286
+ <span className="ovt-glabel mono">{g.name}<span className="ovt-gn"> {shown.length}</span></span>
287
+ <div className="ovt-grid">{shown}</div>
288
+ </div>,
289
+ );
290
+ }
291
+ }
292
+ flushLoose();
293
+
294
+ const openSess = openId ? sessById[openId] : null;
295
+ const windowEl = openSess && (
296
+ <div className="ovw-backdrop" onClick={() => setOpenId(null)}>
297
+ <div className="ovw-win" onClick={(e) => e.stopPropagation()}>
298
+ <Card s={dataFor(openSess)} color={colorOf[openSess.cli]} onOpen={onOpen} onClose={() => setOpenId(null)} />
299
+ </div>
300
+ </div>
301
+ );
302
+
303
+ if (view === 'tiles') {
304
+ return (
305
+ <div className="ov-wrap">
306
+ <div className="ov-feed ovt-feed">
307
+ {tileBlocks.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>}
308
+ {tileBlocks}
309
+ </div>
310
+ {windowEl}
311
+ </div>
312
+ );
313
+ }
314
+
315
  const blocks: ReactNode[] = [];
316
  for (const ref of tree.order) {
317
  if (ref.startsWith('s:')) {
web/src/components/SettingsView.tsx CHANGED
@@ -341,6 +341,22 @@ export default function SettingsView({
341
  />
342
  </span>
343
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  </>
345
  )}
346
 
 
341
  />
342
  </span>
343
  </div>
344
+
345
+ <div className="setting-row">
346
+ <div>
347
+ <div className="s-label">Archive inactive sessions</div>
348
+ <div className="s-help">Sessions with no activity beyond this window disappear from the sidebar and overview. Nothing is deleted; the "archived" checkbox in the sidebar brings them back.</div>
349
+ </div>
350
+ <span className="cfg-ctl">
351
+ <div className="seg cfg-seg">
352
+ {(['week', 'month', 'never'] as const).map((a) => (
353
+ <button key={a} className={cfg.archive.after === a ? 'on' : ''} onClick={() => setCfg({ ...cfg, archive: { after: a } })}>
354
+ {a === 'week' ? '1 week' : a === 'month' ? '1 month' : 'never'}
355
+ </button>
356
+ ))}
357
+ </div>
358
+ </span>
359
+ </div>
360
  </>
361
  )}
362
 
web/src/components/Sidebar.tsx CHANGED
@@ -21,6 +21,7 @@ export default function Sidebar({
21
  clis, tree, activeRef, focusedId, defaultPath, ages,
22
  onActivate, onOpenSession, onNewSession, onNewGroup, onRenameGroup, onRenameSession, onDeleteGroup,
23
  onStopSession, onDeleteSession, onMove, onDragState, onOpenSettings, theme, onToggleTheme, onQuickStart,
 
24
  }: {
25
  clis: Cli[];
26
  tree: Tree;
@@ -42,12 +43,18 @@ export default function Sidebar({
42
  onDragState?: (ref: string | null) => void; // lets the stage offer per-tile drop targets
43
  theme: 'light' | 'dark';
44
  onToggleTheme: () => void;
45
- onQuickStart: (cli: string, prompt: string) => void;
 
 
 
46
  }) {
47
  const [panel, setPanel] = useState<'none' | 'create' | 'quick'>('none');
 
48
  const [quickCli, setQuickCli] = useState<string | null>(null);
49
  const [quickPrompt, setQuickPrompt] = useState('');
50
- const [createTab, setCreateTab] = useState<'agent' | 'group'>('agent');
 
 
51
  // When creation was launched from a group's + the new agent lands there.
52
  const [createTarget, setCreateTarget] = useState<string | null>(null);
53
  const [groupName, setGroupName] = useState('');
@@ -65,12 +72,12 @@ export default function Sidebar({
65
  const waiting = tree.sessions.filter((s) => s.state === 'waiting').length;
66
 
67
  const clearDrag = () => { setDragRef(null); setDrop(null); onDragState?.(null); };
 
 
68
  const bump = (id: string, d: number) => setCart((c) => ({ ...c, [id]: Math.max(0, (c[id] || 0) + d) }));
69
  const closePanel = () => { setPanel('none'); setCreateTarget(null); };
70
- const openCreate = (tab: 'agent' | 'group', target: string | null = null) => {
71
- setCreateTab(tab);
72
  setCreateTarget(target);
73
- setGroupLoc(defaultPath || '.');
74
  setPanel('create');
75
  };
76
  // Quickstart: one harness pick + one prompt, agent launches in workspace/.
@@ -145,7 +152,7 @@ export default function Sidebar({
145
  return (
146
  <div
147
  key={s.id}
148
- className={`row session${active ? ' active' : ''}${nested ? ' nested' : ''}${dragRef === ref ? ' dragging' : ''}${dnd.className}`}
149
  draggable={dnd.draggable}
150
  onDragStart={dnd.onDragStart} onDragEnd={dnd.onDragEnd} onDragOver={dnd.onDragOver} onDrop={dnd.onDrop}
151
  onClick={() => onOpenSession(s.id, groupId)}
@@ -341,9 +348,20 @@ export default function Sidebar({
341
  {tree.order.length === 0 && (
342
  <div className="empty-hint">Nothing yet. Add an agent with the + above.<br />Drag an agent onto another to group them.</div>
343
  )}
344
- {tree.order.map((ref) => (ref.startsWith('s:')
345
- ? (sessById[ref.slice(2)] ? SessionRow(sessById[ref.slice(2)]) : null)
346
- : (groupById[ref.slice(2)] ? GroupBlock(groupById[ref.slice(2)]) : null)))}
 
 
 
 
 
 
 
 
 
 
 
347
  </div>
348
 
349
  {/* Quick-add utilities: created instantly with a default name (double-
@@ -359,9 +377,15 @@ export default function Sidebar({
359
 
360
  <div className="legend">
361
  <span><span className="status working" /> working</span>
362
- <span><span className="status waiting" /> your turn</span>
363
- <span><span className="status idle" /> idle</span>
364
  <span><span className="status stopped" /> stopped</span>
 
 
 
 
 
 
 
365
  </div>
366
  </aside>
367
  );
 
21
  clis, tree, activeRef, focusedId, defaultPath, ages,
22
  onActivate, onOpenSession, onNewSession, onNewGroup, onRenameGroup, onRenameSession, onDeleteGroup,
23
  onStopSession, onDeleteSession, onMove, onDragState, onOpenSettings, theme, onToggleTheme, onQuickStart,
24
+ archived, showArchived, onToggleArchived,
25
  }: {
26
  clis: Cli[];
27
  tree: Tree;
 
43
  onDragState?: (ref: string | null) => void; // lets the stage offer per-tile drop targets
44
  theme: 'light' | 'dark';
45
  onToggleTheme: () => void;
46
+ onQuickStart: (cli: string, prompt: string, name?: string, path?: string) => void;
47
+ archived: Set<string>;
48
+ showArchived: boolean;
49
+ onToggleArchived: () => void;
50
  }) {
51
  const [panel, setPanel] = useState<'none' | 'create' | 'quick'>('none');
52
+ const [quickMode, setQuickMode] = useState<'agent' | 'group'>('agent');
53
  const [quickCli, setQuickCli] = useState<string | null>(null);
54
  const [quickPrompt, setQuickPrompt] = useState('');
55
+ const [quickMore, setQuickMore] = useState(false); // reveals name + folder
56
+ const [quickName, setQuickName] = useState('');
57
+ const [quickLoc, setQuickLoc] = useState('.');
58
  // When creation was launched from a group's + the new agent lands there.
59
  const [createTarget, setCreateTarget] = useState<string | null>(null);
60
  const [groupName, setGroupName] = useState('');
 
72
  const waiting = tree.sessions.filter((s) => s.state === 'waiting').length;
73
 
74
  const clearDrag = () => { setDragRef(null); setDrop(null); onDragState?.(null); };
75
+ // Archived sessions vanish from the tree unless the legend checkbox is on.
76
+ const isHidden = (id: string) => !showArchived && archived.has(id);
77
  const bump = (id: string, d: number) => setCart((c) => ({ ...c, [id]: Math.max(0, (c[id] || 0) + d) }));
78
  const closePanel = () => { setPanel('none'); setCreateTarget(null); };
79
+ const openCreate = (target: string | null = null) => {
 
80
  setCreateTarget(target);
 
81
  setPanel('create');
82
  };
83
  // Quickstart: one harness pick + one prompt, agent launches in workspace/.
 
152
  return (
153
  <div
154
  key={s.id}
155
+ className={`row session${active ? ' active' : ''}${nested ? ' nested' : ''}${archived.has(s.id) ? ' archived' : ''}${dragRef === ref ? ' dragging' : ''}${dnd.className}`}
156
  draggable={dnd.draggable}
157
  onDragStart={dnd.onDragStart} onDragEnd={dnd.onDragEnd} onDragOver={dnd.onDragOver} onDrop={dnd.onDrop}
158
  onClick={() => onOpenSession(s.id, groupId)}
 
348
  {tree.order.length === 0 && (
349
  <div className="empty-hint">Nothing yet. Add an agent with the + above.<br />Drag an agent onto another to group them.</div>
350
  )}
351
+ {tree.order.map((ref) => {
352
+ if (ref.startsWith('s:')) {
353
+ const s = sessById[ref.slice(2)];
354
+ return s && !isHidden(s.id) ? SessionRow(s) : null;
355
+ }
356
+ const g = groupById[ref.slice(2)];
357
+ if (!g) return null;
358
+ // a group whose members are ALL archived disappears with them
359
+ const anyVisible = g.sessionIds.length === 0 || g.sessionIds.some((sid) => sessById[sid] && !isHidden(sid));
360
+ return anyVisible ? GroupBlock(g) : null;
361
+ })}
362
+ {!showArchived && archived.size > 0 && (
363
+ <div className="arch-note">not showing {archived.size} archived session{archived.size === 1 ? '' : 's'}</div>
364
+ )}
365
  </div>
366
 
367
  {/* Quick-add utilities: created instantly with a default name (double-
 
377
 
378
  <div className="legend">
379
  <span><span className="status working" /> working</span>
380
+ <span><span className="status waiting" /> idle</span>
 
381
  <span><span className="status stopped" /> stopped</span>
382
+ {archived.size > 0 && (
383
+ <label className="legend-arch" title="Sessions with no activity beyond the archive window (Settings)">
384
+ <input type="checkbox" checked={showArchived} onChange={onToggleArchived} />
385
+ <span className="lbox" />
386
+ archived
387
+ </label>
388
+ )}
389
  </div>
390
  </aside>
391
  );
web/src/components/icons.tsx CHANGED
@@ -35,6 +35,15 @@ export const SlidersGlyph = ({ className }: { className?: string }) => (
35
  </G>
36
  );
37
 
 
 
 
 
 
 
 
 
 
38
  export const BoltGlyph = ({ className }: { className?: string }) => (
39
  <G className={className}>
40
  <path d="M8.9 1.6 3.9 8.9h3.2l-1 5.5 5-7.3H7.9l1-5.5z" strokeLinejoin="round" />
 
35
  </G>
36
  );
37
 
38
+ export const ListGlyph = ({ className }: { className?: string }) => (
39
+ <G className={className}>
40
+ <path d="M5.6 4.2h8.2M5.6 8h8.2M5.6 11.8h8.2" />
41
+ <circle cx="2.6" cy="4.2" r="0.9" fill="currentColor" stroke="none" />
42
+ <circle cx="2.6" cy="8" r="0.9" fill="currentColor" stroke="none" />
43
+ <circle cx="2.6" cy="11.8" r="0.9" fill="currentColor" stroke="none" />
44
+ </G>
45
+ );
46
+
47
  export const BoltGlyph = ({ className }: { className?: string }) => (
48
  <G className={className}>
49
  <path d="M8.9 1.6 3.9 8.9h3.2l-1 5.5 5-7.3H7.9l1-5.5z" strokeLinejoin="round" />
web/src/styles.css CHANGED
@@ -72,6 +72,10 @@ body {
72
  .stage { flex: 1; min-height: 0; }
73
  .zoombar { display: flex; align-items: center; gap: 4px; padding: 4px 8px; margin-top: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); flex: none; }
74
  .zoombar .spacer { flex: 1; }
 
 
 
 
75
 
76
  /* transparent CLI logos; invert the mostly-black ones in dark mode */
77
  .cli-logo { flex: none; display: inline-flex; align-items: center; justify-content: center; }
@@ -268,16 +272,44 @@ body {
268
  50% { content: '⠼'; } 62.5% { content: '⠴'; } 75% { content: '⠦'; } 87.5% { content: '⠧'; }
269
  }
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  /* reply: a quiet, always-there input line — its ❯ mirrors the prompt's */
272
  .ov-live { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--border); padding-top: 7px; font-size: 13px; }
273
  .ov-live .ov-p { color: var(--accent); font-weight: 700; font-size: 13px; }
274
- .ov-live input { flex: 1; min-width: 0; border: none; background: none; font: inherit; font-size: 13px; color: var(--text); outline: none; padding: 2px 0; }
275
- .ov-live input::placeholder { color: var(--muted); opacity: 0.7; }
276
  .ov-hint { font-size: 10.5px; color: var(--muted); flex: none; }
277
  .ov-note { font-size: 11px; color: var(--danger); }
278
 
279
  /* tree */
280
- .tree { flex: 1; overflow-y: auto; padding: 6px 8px 10px; }
 
281
  .empty-hint { color: var(--muted); font-size: 12px; padding: 12px 10px; line-height: 1.5; }
282
  .empty-hint.nested { padding: 6px 10px 8px 38px; font-size: 11.5px; }
283
 
@@ -360,8 +392,19 @@ body {
360
  .controls .fp-path { font-size: 12px; }
361
  .controls .seg button { font-family: var(--font-mono); font-size: 11.5px; padding: 4px 8px; }
362
 
363
- .legend { display: flex; flex-wrap: wrap; gap: 6px 12px; padding: 10px 16px; border-top: 1px solid var(--border); font-size: 10.5px; color: var(--muted); }
364
  .legend span { display: inline-flex; align-items: center; gap: 5px; }
 
 
 
 
 
 
 
 
 
 
 
365
 
366
  /* main: tiles */
367
  .tiles { height: 100%; display: grid; gap: 12px; }
@@ -749,7 +792,7 @@ a.btn-ghost { text-decoration: none; }
749
  /* 16px inputs stop iOS zoom-on-focus — touch devices only, so a narrow
750
  DESKTOP window keeps the reply line at card size */
751
  @media (pointer: coarse) {
752
- .widget input, .widget select, .row .rename, .secret-desc, .fp-input, .pane-head .ph-title-input, .ov-live input { font-size: 16px; }
753
  }
754
  .ov-headrow { flex-wrap: wrap; }
755
  /* install page: the decorative sidebar makes no sense on a phone */
 
72
  .stage { flex: 1; min-height: 0; }
73
  .zoombar { display: flex; align-items: center; gap: 4px; padding: 4px 8px; margin-top: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); flex: none; }
74
  .zoombar .spacer { flex: 1; }
75
+ /* overview bottom bar: filter + view segments, centered */
76
+ .zoombar.ov-bar { justify-content: center; gap: 8px; }
77
+ .ov-viewseg button { display: inline-flex; align-items: center; padding: 4.5px 9px; }
78
+ .ov-viewseg svg { width: 13px; height: 13px; }
79
 
80
  /* transparent CLI logos; invert the mostly-black ones in dark mode */
81
  .cli-logo { flex: none; display: inline-flex; align-items: center; justify-content: center; }
 
272
  50% { content: '⠼'; } 62.5% { content: '⠴'; } 75% { content: '⠦'; } 87.5% { content: '⠧'; }
273
  }
274
 
275
+ /* ---- overview tile view: compact grid, groups outlined fieldset-style ---- */
276
+ .ovt-feed { gap: 16px; }
277
+ .ovt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); gap: 10px; }
278
+ .ovt-tile { background: var(--panel); border: 1px solid var(--border); border-radius: 11px; padding: 10px 12px 9px; cursor: pointer; display: flex; flex-direction: column; gap: 6px; min-width: 0; transition: border-color 0.12s, transform 0.12s; }
279
+ .ovt-tile:hover { border-color: var(--border-strong); transform: translateY(-1px); }
280
+ .ovt-tile.attn { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent); }
281
+ .ovt-head { display: flex; align-items: center; gap: 7px; min-width: 0; }
282
+ .ovt-name { font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
283
+ .ovt-ago { color: var(--muted); font-size: 11px; flex: none; margin-left: auto; }
284
+ .ovt-prompt { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
285
+ .ovt-prompt::before { content: '❯ '; font-family: var(--font-mono); color: var(--accent); font-weight: 700; }
286
+ .ovt-prompt.none { color: var(--muted); font-style: italic; }
287
+ .ovt-prompt.none::before { color: var(--border-strong); }
288
+ .ovt-state { font-size: 10.5px; color: var(--muted); }
289
+ .ovt-state.running { color: var(--accent); }
290
+ .ovt-state.running::before { content: '⠋ '; display: inline-block; animation: ov-spin 0.9s steps(1) infinite; }
291
+ .ovt-state.stopped { opacity: 0.7; }
292
+ .ovt-group { position: relative; border: 1px solid var(--border); border-radius: 13px; padding: 12px 10px 10px; margin-top: 6px; }
293
+ .ovt-glabel { position: absolute; top: -8px; left: 14px; background: var(--bg); padding: 0 7px; font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; color: var(--muted); }
294
+ .ovt-gn { color: var(--border-strong); }
295
+ /* conversation window: the full card, centered over the grid */
296
+ .ovw-backdrop { position: fixed; inset: 0; z-index: 120; display: flex; align-items: flex-start; justify-content: center; padding: 7vh 20px 20px; background: color-mix(in srgb, #000 52%, transparent); backdrop-filter: blur(3px); animation: rise-in 0.15s ease-out; }
297
+ .ovw-win { width: 100%; max-width: 640px; max-height: 82vh; overflow-y: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 13px; box-shadow: 0 24px 60px rgb(0 0 0 / 0.4); }
298
+ .ovw-win .ov-card { padding: 14px 17px 12px; }
299
+ .ov-x { background: none; border: none; color: var(--muted); font-size: 13px; cursor: pointer; padding: 0 2px 0 8px; flex: none; }
300
+ .ov-x:hover { color: var(--text); }
301
+
302
  /* reply: a quiet, always-there input line — its ❯ mirrors the prompt's */
303
  .ov-live { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--border); padding-top: 7px; font-size: 13px; }
304
  .ov-live .ov-p { color: var(--accent); font-weight: 700; font-size: 13px; }
305
+ .ov-live textarea { flex: 1; min-width: 0; border: none; background: none; font: inherit; font-size: 13px; line-height: 1.45; color: var(--text); outline: none; padding: 2px 0; resize: none; overflow: hidden; max-height: 140px; }
306
+ .ov-live textarea::placeholder { color: var(--muted); opacity: 0.7; }
307
  .ov-hint { font-size: 10.5px; color: var(--muted); flex: none; }
308
  .ov-note { font-size: 11px; color: var(--danger); }
309
 
310
  /* tree */
311
+ .tree { flex: 1; overflow-y: auto; padding: 6px 8px 10px; display: flex; flex-direction: column; }
312
+ .tree > * { flex: none; } /* rows keep their natural height; the arch-note's margin-top:auto sinks it */
313
  .empty-hint { color: var(--muted); font-size: 12px; padding: 12px 10px; line-height: 1.5; }
314
  .empty-hint.nested { padding: 6px 10px 8px 38px; font-size: 11.5px; }
315
 
 
392
  .controls .fp-path { font-size: 12px; }
393
  .controls .seg button { font-family: var(--font-mono); font-size: 11.5px; padding: 4px 8px; }
394
 
395
+ .legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 10px 16px; border-top: 1px solid var(--border); font-size: 10.5px; color: var(--muted); }
396
  .legend span { display: inline-flex; align-items: center; gap: 5px; }
397
+ /* archived toggle: a quiet custom checkbox speaking the app's language */
398
+ .legend-arch { position: relative; display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
399
+ .legend-arch input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
400
+ .legend-arch .lbox { width: 11px; height: 11px; flex: none; border: 1px solid var(--border-strong); border-radius: 3.5px; background: var(--panel-2); display: inline-flex; align-items: center; justify-content: center; transition: background 0.12s, border-color 0.12s; }
401
+ .legend-arch:hover .lbox { border-color: var(--muted); }
402
+ .legend-arch input:checked ~ .lbox { background: var(--accent); border-color: var(--accent); }
403
+ .legend-arch input:checked ~ .lbox::after { content: ''; width: 5px; height: 2.5px; border-left: 1.5px solid var(--accent-fg); border-bottom: 1.5px solid var(--accent-fg); transform: rotate(-45deg) translateY(-1px); }
404
+ /* archived rows/tiles: present but visibly parked */
405
+ .row.archived .name, .row.archived .cli-logo { opacity: 0.45; }
406
+ .ovt-tile.archived .ovt-head { opacity: 0.55; }
407
+ .arch-note { font-size: 10.5px; color: var(--muted); font-style: italic; text-align: center; padding: 14px 12px 4px; margin-top: auto; }
408
 
409
  /* main: tiles */
410
  .tiles { height: 100%; display: grid; gap: 12px; }
 
792
  /* 16px inputs stop iOS zoom-on-focus — touch devices only, so a narrow
793
  DESKTOP window keeps the reply line at card size */
794
  @media (pointer: coarse) {
795
+ .widget input, .widget select, .row .rename, .secret-desc, .fp-input, .pane-head .ph-title-input, .ov-live textarea { font-size: 16px; }
796
  }
797
  .ov-headrow { flex-wrap: wrap; }
798
  /* install page: the decorative sidebar makes no sense on a phone */