288 lines
7.9 KiB
Svelte
288 lines
7.9 KiB
Svelte
<script lang="ts">
|
|
import type { ProjectConfig } from '../../types/groups';
|
|
import { PROJECT_ACCENTS } from '../../types/groups';
|
|
import type { ProjectHealth } from '../../stores/health.svelte';
|
|
import { acknowledgeConflicts } from '../../stores/conflicts.svelte';
|
|
import { ProjectId } from '../../types/ids';
|
|
|
|
interface Props {
|
|
project: ProjectConfig;
|
|
slotIndex: number;
|
|
active: boolean;
|
|
health: ProjectHealth | null;
|
|
onclick: () => void;
|
|
}
|
|
|
|
let { project, slotIndex, active, health, onclick }: Props = $props();
|
|
|
|
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
|
|
|
|
/** Shorten home dir for display */
|
|
let displayCwd = $derived(() => {
|
|
const home = '/home/';
|
|
const cwd = project.cwd || '~';
|
|
if (cwd.startsWith(home)) {
|
|
const afterHome = cwd.slice(home.length);
|
|
const slashIdx = afterHome.indexOf('/');
|
|
if (slashIdx >= 0) return '~' + afterHome.slice(slashIdx);
|
|
return '~';
|
|
}
|
|
return cwd;
|
|
});
|
|
|
|
let statusDotClass = $derived(() => {
|
|
if (!health) return 'dot-inactive';
|
|
switch (health.activityState) {
|
|
case 'running': return 'dot-running';
|
|
case 'idle': return 'dot-idle';
|
|
case 'stalled': return 'dot-stalled';
|
|
default: return 'dot-inactive';
|
|
}
|
|
});
|
|
|
|
let statusTooltip = $derived(() => {
|
|
if (!health) return 'No active session';
|
|
switch (health.activityState) {
|
|
case 'running': return health.activeTool ? `Running: ${health.activeTool}` : 'Running';
|
|
case 'idle': {
|
|
const secs = Math.floor(health.idleDurationMs / 1000);
|
|
return secs < 60 ? `Idle (${secs}s)` : `Idle (${Math.floor(secs / 60)}m ${secs % 60}s)`;
|
|
}
|
|
case 'stalled': {
|
|
const mins = Math.floor(health.idleDurationMs / 60_000);
|
|
return `Stalled — ${mins} min since last activity`;
|
|
}
|
|
default: return 'Inactive';
|
|
}
|
|
});
|
|
|
|
let contextPct = $derived(health?.contextPressure !== null && health?.contextPressure !== undefined
|
|
? Math.round(health.contextPressure * 100)
|
|
: null);
|
|
|
|
let ctxColor = $derived(() => {
|
|
if (contextPct === null) return '';
|
|
if (contextPct > 90) return 'var(--ctp-red)';
|
|
if (contextPct > 75) return 'var(--ctp-peach)';
|
|
if (contextPct > 50) return 'var(--ctp-yellow)';
|
|
return 'var(--ctp-overlay0)';
|
|
});
|
|
</script>
|
|
|
|
<button
|
|
class="project-header"
|
|
class:active
|
|
style="--accent: var({accentVar})"
|
|
{onclick}
|
|
>
|
|
<div class="header-main">
|
|
<span class="status-dot {statusDotClass()}" title={statusTooltip()}></span>
|
|
<span class="project-icon">{project.icon || '📁'}</span>
|
|
<span class="project-name">{project.name}</span>
|
|
<span class="project-id">({project.identifier})</span>
|
|
</div>
|
|
<div class="header-info">
|
|
{#if health && health.externalConflictCount > 0}
|
|
<button
|
|
class="info-conflict info-conflict-external"
|
|
title="{health.externalConflictCount} external write{health.externalConflictCount > 1 ? 's' : ''} — files modified outside agent — click to dismiss"
|
|
onclick={(e: MouseEvent) => { e.stopPropagation(); acknowledgeConflicts(ProjectId(project.id)); }}
|
|
>
|
|
⚡ {health.externalConflictCount} ext write{health.externalConflictCount > 1 ? 's' : ''} ✕
|
|
</button>
|
|
<span class="info-sep">·</span>
|
|
{/if}
|
|
{#if health && health.fileConflictCount - (health.externalConflictCount ?? 0) > 0}
|
|
<button
|
|
class="info-conflict"
|
|
title="{health.fileConflictCount - (health.externalConflictCount ?? 0)} agent conflict{health.fileConflictCount - (health.externalConflictCount ?? 0) > 1 ? 's' : ''} — click to dismiss"
|
|
onclick={(e: MouseEvent) => { e.stopPropagation(); acknowledgeConflicts(ProjectId(project.id)); }}
|
|
>
|
|
⚠ {health.fileConflictCount - (health.externalConflictCount ?? 0)} conflict{health.fileConflictCount - (health.externalConflictCount ?? 0) > 1 ? 's' : ''} ✕
|
|
</button>
|
|
<span class="info-sep">·</span>
|
|
{/if}
|
|
{#if contextPct !== null && contextPct > 0}
|
|
<span class="info-ctx" style="color: {ctxColor()}" title="Context window usage">ctx {contextPct}%</span>
|
|
<span class="info-sep">·</span>
|
|
{/if}
|
|
{#if health && health.burnRatePerHour > 0.01}
|
|
<span class="info-rate" title="Burn rate">
|
|
${health.burnRatePerHour < 1 ? health.burnRatePerHour.toFixed(2) : health.burnRatePerHour.toFixed(1)}/hr
|
|
</span>
|
|
<span class="info-sep">·</span>
|
|
{/if}
|
|
<span class="info-cwd" title={project.cwd}>{displayCwd()}</span>
|
|
{#if project.profile}
|
|
<span class="info-sep">·</span>
|
|
<span class="info-profile" title={project.profile}>{project.profile}</span>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
|
|
<style>
|
|
.project-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
padding: 0.375rem 0.625rem;
|
|
background: var(--ctp-mantle);
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
color: var(--ctp-subtext0);
|
|
font-size: 0.78rem;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
width: 100%;
|
|
text-align: left;
|
|
transition: color 0.15s, border-color 0.15s;
|
|
}
|
|
|
|
.project-header:hover {
|
|
color: var(--ctp-text);
|
|
}
|
|
|
|
.project-header.active {
|
|
color: var(--ctp-text);
|
|
border-bottom-color: var(--accent);
|
|
}
|
|
|
|
.header-main {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
min-width: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Status dot */
|
|
.status-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dot-inactive {
|
|
background: var(--ctp-surface2);
|
|
}
|
|
|
|
.dot-running {
|
|
background: var(--ctp-green);
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.dot-idle {
|
|
background: var(--ctp-overlay0);
|
|
}
|
|
|
|
.dot-stalled {
|
|
background: var(--ctp-peach);
|
|
animation: pulse 1s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.3; }
|
|
}
|
|
|
|
.project-icon {
|
|
font-size: 0.85rem;
|
|
line-height: 1;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.project-name {
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.project-id {
|
|
color: var(--ctp-overlay0);
|
|
font-size: 0.7rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.header-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
min-width: 0;
|
|
flex-shrink: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.info-ctx {
|
|
font-size: 0.6rem;
|
|
font-weight: 600;
|
|
font-family: var(--font-mono, monospace);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.info-rate {
|
|
font-size: 0.6rem;
|
|
color: var(--ctp-mauve);
|
|
font-family: var(--font-mono, monospace);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.info-cwd {
|
|
font-size: 0.65rem;
|
|
color: var(--ctp-overlay0);
|
|
font-family: var(--font-mono, monospace);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
direction: rtl;
|
|
text-align: left;
|
|
unicode-bidi: plaintext;
|
|
max-width: 12rem;
|
|
}
|
|
|
|
.info-sep {
|
|
color: var(--ctp-surface2);
|
|
font-size: 0.6rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.info-conflict {
|
|
font-size: 0.6rem;
|
|
color: var(--ctp-red);
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
|
|
padding: 0.0625rem 0.375rem;
|
|
border-radius: 0.1875rem;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
line-height: inherit;
|
|
}
|
|
|
|
.info-conflict:hover {
|
|
background: color-mix(in srgb, var(--ctp-red) 25%, transparent);
|
|
}
|
|
|
|
.info-conflict-external {
|
|
color: var(--ctp-peach);
|
|
background: color-mix(in srgb, var(--ctp-peach) 12%, transparent);
|
|
}
|
|
|
|
.info-conflict-external:hover {
|
|
background: color-mix(in srgb, var(--ctp-peach) 25%, transparent);
|
|
}
|
|
|
|
.info-profile {
|
|
font-size: 0.65rem;
|
|
color: var(--ctp-blue);
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 8rem;
|
|
background: color-mix(in srgb, var(--ctp-blue) 10%, transparent);
|
|
padding: 0.0625rem 0.375rem;
|
|
border-radius: 0.1875rem;
|
|
}
|
|
</style>
|