- i18n.svelte.ts: store with $state locale + createIntl(), t() function, formatDate/Number/RelativeTime, getDir() for RTL, async setLocale() - i18n.types.ts: TranslationKey union (codegen from en.json) - locales/en.json: 200+ strings in ICU MessageFormat - locales/pl.json: full Polish translation - locales/ar.json: partial Arabic (validates 6-form plural + RTL) - scripts/i18n-types.ts: codegen script for type-safe keys - 6 components wired: StatusBar, AgentPane, CommandPalette, SettingsDrawer, SplashScreen, ChatInput - Language selector in AppearanceSettings - App.svelte: document.dir reactive for RTL - CONTRIBUTING_I18N.md: guide for adding languages Note: currently Electrobun-only. Will extract to @agor/i18n shared package for both Tauri and Electrobun.
276 lines
7.3 KiB
Svelte
276 lines
7.3 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
getHealthAggregates,
|
|
getAttentionQueue,
|
|
type ProjectHealth,
|
|
} from './health-store.svelte.ts';
|
|
import { t } from './i18n.svelte.ts';
|
|
|
|
interface Props {
|
|
projectCount: number;
|
|
totalTokens: number;
|
|
totalCost: number;
|
|
sessionDuration: string;
|
|
groupName: string;
|
|
onFocusProject?: (projectId: string) => void;
|
|
}
|
|
|
|
let {
|
|
projectCount,
|
|
totalTokens,
|
|
totalCost,
|
|
sessionDuration,
|
|
groupName,
|
|
onFocusProject,
|
|
}: Props = $props();
|
|
|
|
let health = $derived(getHealthAggregates());
|
|
let attentionQueue = $derived(getAttentionQueue(5));
|
|
let showAttention = $state(false);
|
|
|
|
function formatRate(rate: number): string {
|
|
if (rate < 0.01) return '$0/hr';
|
|
if (rate < 1) return `$${rate.toFixed(2)}/hr`;
|
|
return `$${rate.toFixed(1)}/hr`;
|
|
}
|
|
|
|
function fmtTokens(n: number): string {
|
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
}
|
|
|
|
function fmtCost(n: number): string {
|
|
return `$${n.toFixed(3)}`;
|
|
}
|
|
|
|
function attentionColor(item: ProjectHealth): string {
|
|
if (item.attentionScore >= 90) return 'var(--ctp-red)';
|
|
if (item.attentionScore >= 70) return 'var(--ctp-peach)';
|
|
if (item.attentionScore >= 40) return 'var(--ctp-yellow)';
|
|
return 'var(--ctp-overlay1)';
|
|
}
|
|
|
|
function focusProject(projectId: string) {
|
|
onFocusProject?.(projectId);
|
|
showAttention = false;
|
|
}
|
|
</script>
|
|
|
|
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
|
|
<!-- Left: agent state counts -->
|
|
{#if health.running > 0}
|
|
<span class="status-segment">
|
|
<span class="dot green pulse-dot" aria-hidden="true"></span>
|
|
<span class="val">{health.running}</span>
|
|
<span>{t('statusbar.running')}</span>
|
|
</span>
|
|
{/if}
|
|
{#if health.idle > 0}
|
|
<span class="status-segment">
|
|
<span class="dot gray" aria-hidden="true"></span>
|
|
<span class="val">{health.idle}</span>
|
|
<span>{t('statusbar.idle')}</span>
|
|
</span>
|
|
{/if}
|
|
{#if health.stalled > 0}
|
|
<span class="status-segment stalled">
|
|
<span class="dot orange" aria-hidden="true"></span>
|
|
<span class="val">{health.stalled}</span>
|
|
<span>{t('statusbar.stalled')}</span>
|
|
</span>
|
|
{/if}
|
|
|
|
<!-- Attention queue -->
|
|
{#if attentionQueue.length > 0}
|
|
<button
|
|
class="status-segment attn-btn"
|
|
class:attn-open={showAttention}
|
|
onclick={() => showAttention = !showAttention}
|
|
title={t('statusbar.needsAttention')}
|
|
>
|
|
<span class="dot orange pulse-dot" aria-hidden="true"></span>
|
|
<span class="val">{attentionQueue.length}</span>
|
|
<span>{t('statusbar.attention')}</span>
|
|
</button>
|
|
{/if}
|
|
|
|
<span class="spacer"></span>
|
|
|
|
<!-- Right: aggregates -->
|
|
{#if health.totalBurnRatePerHour > 0}
|
|
<span class="status-segment burn" title={t('statusbar.burnRate')}>
|
|
<span class="val">{formatRate(health.totalBurnRatePerHour)}</span>
|
|
</span>
|
|
{/if}
|
|
|
|
<span class="status-segment" title={t('statusbar.activeGroup')}>
|
|
<span class="val">{groupName}</span>
|
|
</span>
|
|
<span class="status-segment" title={t('statusbar.projects')}>
|
|
<span class="val">{projectCount}</span>
|
|
<span>{t('statusbar.projects')}</span>
|
|
</span>
|
|
<span class="status-segment" title={t('statusbar.session')}>
|
|
<span>{t('statusbar.session')}</span>
|
|
<span class="val">{sessionDuration}</span>
|
|
</span>
|
|
<span class="status-segment" title={t('statusbar.tokens')}>
|
|
<span>{t('statusbar.tokens')}</span>
|
|
<span class="val">{fmtTokens(totalTokens)}</span>
|
|
</span>
|
|
<span class="status-segment" title={t('statusbar.cost')}>
|
|
<span>{t('statusbar.cost')}</span>
|
|
<span class="val cost">{fmtCost(totalCost)}</span>
|
|
</span>
|
|
|
|
<kbd class="palette-hint" title={t('statusbar.search')}>Ctrl+Shift+F</kbd>
|
|
</footer>
|
|
|
|
<!-- Attention dropdown -->
|
|
{#if showAttention && attentionQueue.length > 0}
|
|
<div class="attention-panel">
|
|
{#each attentionQueue as item (item.projectId)}
|
|
<button
|
|
class="attention-card"
|
|
onclick={() => focusProject(item.projectId)}
|
|
>
|
|
<span class="card-id">{item.projectId.slice(0, 12)}</span>
|
|
<span class="card-reason" style="color: {attentionColor(item)}">{item.attentionReason}</span>
|
|
{#if item.contextPressure !== null && item.contextPressure > 0.5}
|
|
<span class="card-ctx">ctx {Math.round(item.contextPressure * 100)}%</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.status-bar {
|
|
height: var(--status-bar-height, 1.5rem);
|
|
background: var(--ctp-crust);
|
|
border-top: 1px solid var(--ctp-surface0);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.875rem;
|
|
padding: 0 0.625rem;
|
|
flex-shrink: 0;
|
|
font-size: 0.6875rem;
|
|
color: var(--ctp-subtext0);
|
|
font-family: var(--ui-font-family, system-ui, sans-serif);
|
|
user-select: none;
|
|
position: relative;
|
|
}
|
|
|
|
.status-segment {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dot {
|
|
width: 0.4375rem;
|
|
height: 0.4375rem;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dot.green { background: var(--ctp-green); }
|
|
.dot.gray { background: var(--ctp-overlay0); }
|
|
.dot.orange { background: var(--ctp-peach); }
|
|
|
|
.pulse-dot {
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.3; }
|
|
}
|
|
|
|
.val { color: var(--ctp-text); font-weight: 500; }
|
|
.cost { color: var(--ctp-yellow); }
|
|
.burn { color: var(--ctp-mauve); font-weight: 600; }
|
|
.stalled { color: var(--ctp-peach); font-weight: 600; }
|
|
.spacer { flex: 1; }
|
|
|
|
/* Attention button */
|
|
.attn-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--ctp-peach);
|
|
font: inherit;
|
|
font-size: inherit;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.attn-btn:hover, .attn-btn.attn-open {
|
|
color: var(--ctp-red);
|
|
}
|
|
|
|
/* Attention panel */
|
|
.attention-panel {
|
|
position: absolute;
|
|
bottom: var(--status-bar-height, 1.5rem);
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--ctp-surface0);
|
|
border-top: 1px solid var(--ctp-surface1);
|
|
display: flex;
|
|
gap: 1px;
|
|
padding: 0.25rem 0.5rem;
|
|
z-index: 100;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.attention-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.25rem 0.5rem;
|
|
background: var(--ctp-base);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.25rem;
|
|
color: var(--ctp-text);
|
|
font: inherit;
|
|
font-size: 0.6875rem;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.attention-card:hover {
|
|
background: var(--ctp-surface0);
|
|
border-color: var(--ctp-surface2);
|
|
}
|
|
|
|
.card-id { font-weight: 600; }
|
|
|
|
.card-reason { font-size: 0.625rem; }
|
|
|
|
.card-ctx {
|
|
font-size: 0.5625rem;
|
|
color: var(--ctp-overlay0);
|
|
background: color-mix(in srgb, var(--ctp-yellow) 10%, transparent);
|
|
padding: 0 0.25rem;
|
|
border-radius: 0.125rem;
|
|
}
|
|
|
|
.palette-hint {
|
|
padding: 0.1rem 0.3rem;
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.2rem;
|
|
font-size: 0.6rem;
|
|
color: var(--ctp-overlay0);
|
|
font-family: var(--ui-font-family, system-ui, sans-serif);
|
|
cursor: pointer;
|
|
transition: color 0.1s;
|
|
}
|
|
|
|
.palette-hint:hover { color: var(--ctp-subtext0); }
|
|
</style>
|