Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210<script lang="ts"> import type { SessionSummary } from "@/contracts/session-summary"; import { createEventDispatcher } from "svelte";
/** * A compact, project-agnostic activity strip docked atop the session catalog. It does not * replace the catalog: it lifts live work and the latest touched sessions above project * grouping so an agent buried in a collapsed/off-screen project stays one click away. * * Designed for the narrow catalog rail (~280–320px): single dense line per agent, a small * severity signal, truncated title, and a tiny project·time tail. Renders nothing when * there is no live or recent activity, so the catalog keeps its full height. */ export let sessions: SessionSummary[] = []; export let selectedSessionId: string | null = null; export let now = Date.now(); export let recentWindowMs = 24 * 60 * 60 * 1000; export let maxRecent = 5;
const dispatch = createEventDispatcher<{ select: string }>();
function isRunning(session: SessionSummary): boolean { const state = session.runtime?.runtime_state; return state === "running" || state === "starting"; }
function attentionRank(session: SessionSummary): number { const status = session.runtime?.status; if (status === "waiting_for_permission" || status === "waiting_for_ui") return 0; if (status === "error" || session.runtime?.runtime_state === "failed") return 1; if (status === "streaming" || status === "compacting") return 2; return 3; }
function titleFor(session: SessionSummary): string { return session.title?.trim() || session.preview.trim() || "Untitled session"; }
function projectFor(cwd: string): string { const trimmed = cwd.replace(/[\\/]+$/, ""); return trimmed.split(/[\\/]/).pop() || trimmed; }
function timeAgo(timestamp: number): string { const seconds = Math.max(0, Math.floor((now - timestamp) / 1000)); if (seconds < 45) return "now"; if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; return `${Math.floor(seconds / 86400)}d`; }
function activityLabel(session: SessionSummary): string { const status = session.runtime?.status; if (status === "waiting_for_permission") return "approval"; if (status === "waiting_for_ui") return "input"; if (status === "streaming") return "working"; if (status === "compacting") return "compacting"; if (status === "error") return "error"; if (session.runtime?.runtime_state === "starting") return "starting"; return "running"; }
function activityClass(session: SessionSummary): string { const status = session.runtime?.status; if (status === "waiting_for_permission" || status === "waiting_for_ui") return "waiting"; if (status === "error") return "error"; if (status === "streaming" || status === "compacting") return "working"; return "running"; }
$: running = sessions .filter(isRunning) .sort((a, b) => attentionRank(a) - attentionRank(b) || b.modified_at - a.modified_at); $: recent = sessions .filter((session) => !isRunning(session) && now - session.modified_at <= recentWindowMs) .sort((a, b) => b.modified_at - a.modified_at) .slice(0, maxRecent); $: hasActivity = running.length > 0 || recent.length > 0;</script>
{#if hasActivity} <section class="activity" aria-label="Running agents and recent activity"> <div class="head"> <span class="tracked">Activity</span> {#if running.length > 0}<span class="live-count mono">{running.length} live</span>{/if} </div>
<ul class="rows" role="list"> {#each running as session (session.session_id)} <li role="listitem"> <button type="button" class="row live {activityClass(session)}" class:selected={session.session_id === selectedSessionId} title="{titleFor(session)} · {projectFor(session.cwd)} — {activityLabel(session)}" on:click={() => dispatch("select", session.session_id)} > <span class="signal {activityClass(session)}" aria-hidden="true"></span> <span class="title">{titleFor(session)}</span> <span class="tail mono">{activityLabel(session)}</span> </button> </li> {/each}
{#each recent as session (session.session_id)} <li role="listitem"> <button type="button" class="row recent" class:selected={session.session_id === selectedSessionId} title="{titleFor(session)} · {projectFor(session.cwd)} — {timeAgo(session.modified_at)} ago" on:click={() => dispatch("select", session.session_id)} > <span class="recent-mark" aria-hidden="true"></span> <span class="title">{titleFor(session)}</span> <span class="tail mono">{projectFor(session.cwd)} · {timeAgo(session.modified_at)}</span> </button> </li> {/each} </ul> </section>{/if}
<style> .activity { flex: none; border-bottom: 1px solid var(--hairline); background: var(--surface-sunken); padding: 4px 10px 6px; position: relative; z-index: 3; } .head { display: flex; align-items: baseline; gap: 8px; padding: 2px 2px 4px; } .head .tracked { font-size: 10px; color: var(--ink-3); } .live-count { font-size: 9.5px; color: var(--sev-success-ink); }
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
.row { width: 100%; height: 22px; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; align-items: center; gap: 6px; padding: 0 5px; border: 1px solid transparent; border-radius: var(--r-xs); background: transparent; color: var(--ink-2); font: inherit; font-size: 11.5px; text-align: left; cursor: pointer; transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); } .row:hover { background: var(--surface-hover); color: var(--ink); } .row.selected { background: var(--accent-soft); border-color: var(--accent-soft-strong); color: var(--ink); box-shadow: inset 2px 0 0 var(--accent); }
.title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } .tail { font-size: 9.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; } .live .tail { max-width: 72px; } .live.working .tail { color: var(--sev-success-ink); } .live.waiting .tail { color: var(--sev-warn-ink); font-weight: 500; } .live.error .tail { color: var(--sev-error-ink); }
/* Severity signal — a 6px dot, calmer than the catalog's 8px. */ .signal { width: 6px; height: 6px; justify-self: center; border-radius: var(--r-pill); background: var(--sev-success); position: relative; } .signal.working::after { content: ""; position: absolute; inset: -3px; border: 1px solid var(--live-pulse); border-radius: inherit; animation: activity-pulse 1.8s var(--ease-soft) infinite; } .signal.waiting { background: var(--sev-warn); } .signal.error { background: var(--sev-error); }
.recent-mark { width: 4px; height: 4px; justify-self: center; border-radius: var(--r-pill); background: var(--hairline-strong); }
@keyframes activity-pulse { 0% { transform: scale(.7); opacity: .8; } 75%, 100% { transform: scale(1.7); opacity: 0; } } @media (prefers-reduced-motion: reduce) { .signal.working::after { animation: none; display: none; } }</style>