Spaces:
Running
Overview tiles + conversation window; archive inactive sessions
Browse filesThe 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 +6 -0
- web/src/App.tsx +40 -2
- web/src/api.ts +1 -0
- web/src/components/Overview.tsx +107 -10
- web/src/components/SettingsView.tsx +16 -0
- web/src/components/Sidebar.tsx +35 -11
- web/src/components/icons.tsx +9 -0
- web/src/styles.css +48 -5
|
@@ -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());
|
|
@@ -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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 && (
|
|
@@ -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);
|
|
@@ -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<
|
| 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
|
| 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 |
-
<
|
| 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 |
-
|
| 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) =>
|
|
|
|
| 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:')) {
|
|
@@ -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 |
|
|
@@ -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 [
|
|
|
|
|
|
|
| 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 = (
|
| 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) =>
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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" />
|
| 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 |
);
|
|
@@ -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" />
|
|
@@ -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
|
| 275 |
-
.ov-live
|
| 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
|
| 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 */
|