diff --git a/src/app.css b/src/app.css
index 091c4f6..6cc5196 100644
--- a/src/app.css
+++ b/src/app.css
@@ -59,7 +59,7 @@
--success: #2ec27e;
--warn: #f6d32d;
--danger: #e01b24;
- /* Session context meter tones, indexed by the agent-advertised mode order
+ /* Session context ring tones, indexed by the agent-advertised mode order
(see getSessionContextTone). Values stay muted so any tone blends with the
warm gray surfaces; the dark theme swaps in lighter counterparts. */
--mode-tone-0: #c05621;
@@ -5502,12 +5502,6 @@ summary:focus-visible {
-webkit-backdrop-filter: var(--surface-blur);
}
-/* Second header row: the full-width session context meter. */
-.session-header-context {
- grid-column: 1 / -1;
- min-width: 0;
-}
-
.session-header-back {
flex: 0 0 auto;
}
@@ -5530,8 +5524,11 @@ summary:focus-visible {
.session-header-meta {
display: flex;
+ width: max-content;
+ max-width: 100%;
min-width: 0;
align-items: center;
+ justify-content: flex-start;
gap: 0.35rem;
margin-top: 0.15rem;
overflow: hidden;
@@ -5545,17 +5542,53 @@ summary:focus-visible {
display: none;
}
-.session-header-meta span:not([aria-hidden]) {
+.session-header-meta span:not([aria-hidden]):not(.session-context-dial):not(.session-context-dial-figure) {
overflow: hidden;
text-overflow: ellipsis;
}
+.session-header-meta > .session-header-session-id,
+.session-header-meta > .session-header-context-slot,
+.session-header-meta > .session-header-status-wrap,
+.session-header-mobile-meta > .session-context-dial {
+ flex: 0 0 auto;
+}
+
+.session-header-context-slot {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 0.35rem;
+}
+
.session-header-meta-desktop,
.session-header-session-id {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.35rem;
+ font-size: inherit;
+ line-height: inherit;
+}
+
+/* One line box for the whole meta row. Chips otherwise bring their own
+ font-size, line-height, and padding and the row grows to the tallest. */
+.session-header-meta .session-id-chip,
+.session-header-meta .copy-chip,
+.session-header-meta .session-context-dial,
+.session-header-meta .session-header-profile {
+ font-size: inherit;
+ line-height: inherit;
+}
+
+.session-header-meta .session-id-chip,
+.session-header-meta .copy-chip {
+ padding-block: 0;
+}
+
+.session-header-meta .session-header-profile svg {
+ width: 1em;
+ height: 1em;
}
.session-header-controls,
@@ -6045,7 +6078,6 @@ summary:focus-visible {
}
.session-usage-bar {
- --session-context-accent: var(--accent);
display: grid;
gap: 0.6rem;
margin-top: 0.7rem;
@@ -6054,51 +6086,76 @@ summary:focus-visible {
font-size: 0.7rem;
}
-/* Mode tones: indexes follow the agent-advertised mode order (see
- getSessionContextTone); the context meter picks the accent up from here. */
-.session-context-tone-0 { --session-context-accent: var(--mode-tone-0); }
-.session-context-tone-1 { --session-context-accent: var(--mode-tone-1); }
-.session-context-tone-2 { --session-context-accent: var(--mode-tone-2); }
-.session-context-tone-3 { --session-context-accent: var(--mode-tone-3); }
-.session-context-tone-4 { --session-context-accent: var(--mode-tone-4); }
-.session-context-tone-5 { --session-context-accent: var(--mode-tone-5); }
-.session-context-tone-6 { --session-context-accent: var(--mode-tone-6); }
-.session-context-tone-7 { --session-context-accent: var(--mode-tone-7); }
-
-.session-usage-bar.session-usage-variant-context {
- margin-top: 0;
+/* Context is a closed window, not a task bar. The ring is fixed-size so a low
+ reading never stretches into an empty track. The arc stays on the accent hue;
+ pressure only changes how hard that hue reads, never the hue itself. */
+.session-context-dial {
+ --session-context-accent: var(--accent);
+ display: inline-flex;
+ flex: 0 0 auto;
align-items: center;
+ gap: 0.3rem;
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+ font-size: inherit;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+ line-height: inherit;
+ white-space: nowrap;
}
-.session-usage-meta {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 0.7rem;
+.session-context-dial-calm .session-context-dial-arc {
+ opacity: 0.72;
}
-.session-usage-context {
- display: flex;
- align-items: center;
- min-width: 0;
- gap: 0.45rem;
+.session-context-dial-warm {
+ color: var(--text);
}
-.session-usage-icon,
-.session-usage-stat > svg {
+.session-context-dial-tight {
+ color: var(--status-warn);
+}
+
+.session-context-dial-full {
+ color: var(--status-danger);
+}
+
+.session-context-dial-ring {
+ width: 1lh;
+ height: 1lh;
flex: 0 0 auto;
+ overflow: visible;
+}
+
+.session-context-dial-ring circle {
+ fill: none;
+ stroke-width: 2.4;
+ stroke-linecap: round;
+}
+
+.session-context-dial-track {
+ stroke: color-mix(in srgb, var(--text-dim) 42%, transparent);
}
-/* The gauge takes the mode tone; stat icons keep the application accent. */
-.session-usage-icon {
- color: var(--session-context-accent);
+.session-context-dial-arc {
+ stroke: var(--session-context-accent);
+ transform: rotate(-90deg);
+ transform-origin: center;
+ transition: stroke-dashoffset 240ms ease, opacity 180ms ease;
+}
+
+.session-usage-meta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.7rem;
}
.session-usage-stat > svg {
+ flex: 0 0 auto;
color: var(--accent);
}
-.session-usage-label,
.session-usage-stat > span {
color: var(--muted);
font-family: var(--font-sans);
@@ -6108,7 +6165,6 @@ summary:focus-visible {
text-transform: uppercase;
}
-.session-usage-context strong,
.session-usage-stat strong {
color: var(--text);
font-size: 0.72rem;
@@ -6116,33 +6172,6 @@ summary:focus-visible {
white-space: nowrap;
}
-.session-usage-meter {
- position: relative;
- min-width: 3.75rem;
- height: 0.28rem;
- flex: 1 1 8rem;
- overflow: hidden;
- border-radius: 999px;
- background: color-mix(in srgb, var(--session-context-accent) 14%, transparent);
-}
-
-.session-usage-meter span {
- position: absolute;
- inset: 0 auto 0 0;
- width: var(--session-context-percent);
- border-radius: inherit;
- background: var(--session-context-accent);
- box-shadow: 0 0 10px color-mix(in srgb, var(--session-context-accent) 35%, transparent);
- transition: width 240ms ease;
-}
-
-.session-usage-percent {
- min-width: 2.5rem;
- color: var(--text);
- font-weight: 650;
- text-align: right;
-}
-
.session-usage-stat {
display: flex;
align-items: center;
@@ -10413,6 +10442,10 @@ summary:focus-visible {
will-change: auto;
}
+ .session-context-dial-arc {
+ transition: none;
+ }
+
.session-activity-bar-dock,
.session-scroll-bottom-pill-host {
transition: none;
@@ -10813,10 +10846,15 @@ summary:focus-visible {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.64rem;
- line-height: 1.05;
+ line-height: 1.2;
white-space: nowrap;
}
+ .app-shell-active-session .session-header-mobile-meta .session-context-dial {
+ font-size: inherit;
+ line-height: inherit;
+ }
+
.app-shell-active-session .session-header-mobile-workspace,
.app-shell-active-session .session-header-mobile-id {
overflow: hidden;
@@ -10830,7 +10868,7 @@ summary:focus-visible {
.app-shell-active-session .session-header-mobile-id {
min-width: 0;
- flex: 1 1 auto;
+ flex: 0 1 auto;
}
.app-shell-active-session .session-header-details-panel {
@@ -10899,23 +10937,10 @@ summary:focus-visible {
min-height: 1.25rem;
}
- .app-shell-active-session .session-header-details-panel .session-usage-context {
- gap: 0.35rem;
- }
-
- .app-shell-active-session .session-header-details-panel .session-usage-label,
.app-shell-active-session .session-header-details-panel .session-usage-stat > span {
font-size: 0.58rem;
}
- .app-shell-active-session .session-header-details-panel .session-usage-meter {
- min-width: 2.5rem;
- }
-
- .app-shell-active-session .session-header-details-panel .session-usage-percent {
- min-width: 2rem;
- }
-
.app-shell-active-session .session-page-content {
gap: 0.9rem;
padding: 0.85rem 0.75rem var(
diff --git a/src/lib/components/primitives/SessionComposer.svelte b/src/lib/components/primitives/SessionComposer.svelte
index b05ad9a..90362e0 100644
--- a/src/lib/components/primitives/SessionComposer.svelte
+++ b/src/lib/components/primitives/SessionComposer.svelte
@@ -848,7 +848,7 @@
{/if}
-{/snippet}
+ {/snippet}
+ import type { SessionUsageStats } from '$lib/domain/types';
+ import {
+ formatTokenCount,
+ getContextPercent,
+ getContextPressure,
+ getContextRemaining
+ } from '$lib/domain/session-usage';
+
+ let { usage }: { usage: SessionUsageStats } = $props();
+
+ // Closed ring: the circle is the window, the arc is used, the gap is what's left.
+ // A fixed footprint so a low reading never stretches into an empty track.
+ const RING_RADIUS = 7;
+ const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
+
+ const percent = $derived(getContextPercent(usage));
+ const pressure = $derived(getContextPressure(percent));
+ const remaining = $derived(getContextRemaining(usage));
+ const roundedPercent = $derived(percent === null ? null : Math.round(percent));
+ const usedLabel = $derived(formatTokenCount(usage.contextUsed));
+ const limitLabel = $derived(formatTokenCount(usage.contextLimit));
+ const figure = $derived(roundedPercent !== null ? `${roundedPercent}%` : usedLabel);
+ const detail = $derived.by(() => {
+ if (usage.contextUsed === null) return '';
+ if (roundedPercent !== null && usage.contextLimit !== null) {
+ const remainingNote = remaining === null ? '' : `, ${formatTokenCount(remaining)} left`;
+ return `${usedLabel} of ${limitLabel} · ${roundedPercent}% of the context window${remainingNote}`;
+ }
+ return `${usedLabel} in the context window`;
+ });
+ const arcOffset = $derived(
+ percent === null ? RING_CIRCUMFERENCE : RING_CIRCUMFERENCE * (1 - percent / 100)
+ );
+ const rootClass = $derived(`session-context-dial session-context-dial-${pressure}`);
+
+
+{#if usage.contextUsed !== null}
+
+
+ {figure}
+
+{/if}
diff --git a/src/lib/components/session/SessionContextDial.test.ts b/src/lib/components/session/SessionContextDial.test.ts
new file mode 100644
index 0000000..c24b82e
--- /dev/null
+++ b/src/lib/components/session/SessionContextDial.test.ts
@@ -0,0 +1,85 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup, render, screen } from '@testing-library/svelte';
+import { afterEach, describe, expect, it } from 'vitest';
+import SessionContextDial from './SessionContextDial.svelte';
+
+const usage = {
+ contextUsed: 130_000,
+ contextLimit: 1_100_000,
+ cumulativeCostUsd: null,
+ activeWorkMs: 0,
+ activeWorkStartedAt: null
+};
+
+function dial(): HTMLElement {
+ const root = document.querySelector('.session-context-dial');
+ expect(root).not.toBeNull();
+ return root!;
+}
+
+afterEach(cleanup);
+
+describe('SessionContextDial', () => {
+ it('renders a fixed ring and the used count, not a stretched meter', () => {
+ render(SessionContextDial, { usage });
+
+ const root = dial();
+ expect(root.className).not.toContain('session-context-tone');
+ expect(root.classList.contains('session-context-dial-calm')).toBe(true);
+ expect(screen.getByRole('meter', { name: 'Context window' })).toBeTruthy();
+ expect(root).toHaveTextContent('12%');
+ expect(root).not.toHaveTextContent('130k');
+ expect(root.getAttribute('title')).toBe('130k of 1.1m · 12% of the context window, 970k left');
+ expect(root.querySelector('.session-context-dial-arc')).not.toBeNull();
+ expect(root.querySelector('.session-usage-meter')).toBeNull();
+ });
+
+ it('stays hidden until the session reports a reading', () => {
+ render(SessionContextDial, {
+ usage: {
+ contextUsed: null,
+ contextLimit: null,
+ cumulativeCostUsd: null,
+ activeWorkMs: 0,
+ activeWorkStartedAt: null
+ }
+ });
+
+ expect(document.querySelector('.session-context-dial')).toBeNull();
+ expect(screen.queryByText('No usage yet')).toBeNull();
+ });
+
+ it('switches the figure to remaining and warns once the window is tight', () => {
+ render(SessionContextDial, {
+ usage: { ...usage, contextUsed: 990_000 }
+ });
+
+ const root = dial();
+ expect(root.classList.contains('session-context-dial-tight')).toBe(true);
+ expect(root).toHaveTextContent('90%');
+ expect(root).not.toHaveTextContent('left');
+ expect(root.getAttribute('title')).toContain('90% of the context window');
+ expect(root.getAttribute('title')).toContain('110k left');
+ });
+
+ it('marks a nearly full window as full', () => {
+ render(SessionContextDial, {
+ usage: { ...usage, contextUsed: 1_060_000 }
+ });
+
+ expect(dial().classList.contains('session-context-dial-full')).toBe(true);
+ expect(screen.getByText('96%')).toBeTruthy();
+ });
+
+ it('shows used tokens without an arc when the limit is unknown', () => {
+ render(SessionContextDial, {
+ usage: { ...usage, contextLimit: null }
+ });
+
+ const root = dial();
+ expect(root.getAttribute('role')).toBe('img');
+ expect(root.querySelector('.session-context-dial-arc')).toBeNull();
+ expect(root).toHaveTextContent('130k');
+ expect(root.getAttribute('title')).toBe('130k in the context window');
+ });
+});
diff --git a/src/lib/components/session/SessionHeader.svelte b/src/lib/components/session/SessionHeader.svelte
index 4d707a0..e02b517 100644
--- a/src/lib/components/session/SessionHeader.svelte
+++ b/src/lib/components/session/SessionHeader.svelte
@@ -2,6 +2,7 @@
import { ArrowLeft, Bug, FileCog, Info, RefreshCw } from '@lucide/svelte';
import CopyTextChip from '$lib/components/primitives/CopyTextChip.svelte';
import SessionIdChip from '$lib/components/primitives/SessionIdChip.svelte';
+ import SessionContextDial from '$lib/components/session/SessionContextDial.svelte';
import SessionUsageBar from '$lib/components/session/SessionUsageBar.svelte';
import { autoCollapsePopover } from '$lib/design/details-popover';
import type { ActiveSessionViewModel, SessionStatus } from '$lib/domain/types';
@@ -15,7 +16,6 @@
profileLabel = null,
updatedAt,
summaryStatus = 'idle',
- contextTone = null,
debugLabel = 'Debug events',
showDebug = false,
forkPending = false,
@@ -33,8 +33,6 @@
profileLabel?: string | null;
updatedAt: string;
summaryStatus?: SessionStatus;
- /** Index into the mode tone palette for the context meter; null keeps the default accent. */
- contextTone?: number | null;
debugLabel?: string;
showDebug?: boolean;
forkPending?: boolean;
@@ -77,6 +75,7 @@
·
{/if}
+
@@ -133,7 +135,7 @@
Error{session.lastError}
{/if}
-
+
{#if showDebug}
@@ -146,8 +148,4 @@
-
-
diff --git a/src/lib/components/session/SessionHeader.test.ts b/src/lib/components/session/SessionHeader.test.ts
index 098c66b..db9a44d 100644
--- a/src/lib/components/session/SessionHeader.test.ts
+++ b/src/lib/components/session/SessionHeader.test.ts
@@ -34,39 +34,45 @@ describe('SessionHeader', () => {
expect(within(mobileContext).getByText('querymt-desktop')).toBeInTheDocument();
expect(within(mobileContext).getByText('session-1')).toBeInTheDocument();
expect(screen.getByLabelText('Status: Ready')).toBeInTheDocument();
- expect(screen.getByText('Context')).toBeInTheDocument();
+ expect(document.querySelector('.session-header-context')).toBeNull();
});
- it('surfaces context usage in the header without opening the details popover', () => {
+ it('packs header widgets left in status, path, id, profile, context, time order', () => {
render(SessionHeader, {
- session: session({ usage: usageFixture }),
+ session: session({
+ usage: usageFixture,
+ sessionId: '01a072b3-a266-4c5d-8e9f-102030405060'
+ }),
title: 'Visible context',
workspace: 'querymt-desktop',
+ profileLabel: 'def',
updatedAt: 'Just now'
});
- expect(document.querySelector('.session-header-context')).not.toBeNull();
- expect(screen.getByText('24k / 100k')).toBeInTheDocument();
- expect(screen.getByLabelText('Context window 24% used')).toBeInTheDocument();
- expect(screen.getByText('24%')).toBeInTheDocument();
-
- // The popover no longer duplicates the context meter.
- const contextLabels = screen.getAllByText('Context');
- expect(contextLabels).toHaveLength(1);
- });
-
- it('applies the session mode tone to the header context meter', () => {
- render(SessionHeader, {
- session: session({ usage: usageFixture }),
- title: 'Toned context',
- workspace: 'querymt-desktop',
- updatedAt: 'Just now',
- contextTone: 3
- });
-
- const bar = document.querySelector('.session-header-context .session-usage-bar');
- expect(bar).not.toBeNull();
- expect(bar!.classList.contains('session-context-tone-3')).toBe(true);
+ const header = document.querySelector('.session-header');
+ const meta = document.querySelector('.session-header-meta');
+ const mobile = document.querySelector('.session-header-mobile-meta');
+ expect(header).not.toBeNull();
+ expect(header!.querySelector('.session-header-context')).toBeNull();
+ expect(header!.querySelector(':scope > .session-usage-bar')).toBeNull();
+ expect(meta?.querySelector('.session-header-meta-line')).toBeNull();
+
+ const order = [...(meta?.children ?? [])].map((child) => child.className);
+ expect(order[0]).toContain('session-header-status-wrap');
+ expect(order[1]).toContain('session-header-workspace');
+ expect(order[2]).toContain('session-header-session-id');
+ expect(order[3]).toContain('session-header-meta-desktop');
+ expect(meta?.children[3]?.textContent).toContain('def');
+ expect(order[4]).toContain('session-header-context-slot');
+ expect(order[5]).toContain('session-header-meta-desktop');
+ expect(meta?.children[5]?.textContent).toContain('Just now');
+ expect(meta?.querySelector('.session-context-dial')).toHaveTextContent('24%');
+
+ const mobileDial = mobile?.querySelector(':scope > .session-context-dial');
+ const mobileId = mobile?.querySelector('.session-header-mobile-id');
+ expect(mobileId).not.toBeNull();
+ expect(mobileDial).not.toBeNull();
+ expect(mobileId!.nextElementSibling).toBe(mobileDial);
});
it('keeps cost and active time inside the details popover', async () => {
@@ -82,9 +88,11 @@ describe('SessionHeader', () => {
expect(panel).not.toBeNull();
expect(within(panel!).getByText('$0.05')).toBeInTheDocument();
expect(within(panel!).getByText('12s')).toBeInTheDocument();
- // The popover carries stats only; the context meter lives in the header row.
+ expect(within(panel!).getByText('24k / 100k')).toBeInTheDocument();
+ const contextStat = within(panel!).getByText('Context').closest('.session-usage-stat');
+ expect(contextStat?.querySelector('svg')).not.toBeNull();
expect(panel!.querySelector('.session-usage-meter')).toBeNull();
- expect(panel!.querySelector('.session-usage-context')).toBeNull();
+ expect(panel!.querySelector('.session-context-dial')).toBeNull();
});
it('shows a short session id chip that copies the full id', async () => {
@@ -176,7 +184,7 @@ describe('SessionHeader', () => {
expect(details).not.toHaveAttribute('open');
});
- it('keeps header actions available and context always visible', async () => {
+ it('keeps header actions available without a context banner', async () => {
const onBack = vi.fn();
const onRefresh = vi.fn();
render(SessionHeader, {
@@ -190,7 +198,7 @@ describe('SessionHeader', () => {
});
expect(screen.getByLabelText('Status: Working')).toBeInTheDocument();
- expect(screen.getByText('24k / 100k')).toBeInTheDocument();
+ expect(document.querySelector('.session-header-context')).toBeNull();
expect(screen.queryByRole('button', { name: /Fork latest turn|Undo latest turn|Redo last undone turn/ })).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Back to sessions' }));
await fireEvent.click(screen.getByRole('button', { name: 'Refresh session' }));
diff --git a/src/lib/components/session/SessionUsageBar.svelte b/src/lib/components/session/SessionUsageBar.svelte
index 0d7d8dc..106b9b5 100644
--- a/src/lib/components/session/SessionUsageBar.svelte
+++ b/src/lib/components/session/SessionUsageBar.svelte
@@ -6,48 +6,25 @@
formatCostUsd,
formatDuration,
formatTokenCount,
- getActiveWorkMs,
- getContextPercent
+ getActiveWorkMs
} from '$lib/domain/session-usage';
- let {
- usage,
- variant = 'stats',
- tone = null
- }: {
- usage: SessionUsageStats;
- /** `context` renders the full-width context meter row; `stats` renders cost and active time. */
- variant?: 'context' | 'stats';
- /** Index into the mode tone palette (see getSessionContextTone); null keeps the default accent. */
- tone?: number | null;
- } = $props();
+ let { usage }: { usage: SessionUsageStats } = $props();
let now = $state(Date.now());
let timer: ReturnType | null = null;
- const contextPercent = $derived(getContextPercent(usage));
const contextLabel = $derived(
- usage.contextUsed === null
- ? 'No usage yet'
- : usage.contextLimit === null
- ? `${formatTokenCount(usage.contextUsed)} tokens`
- : `${formatTokenCount(usage.contextUsed)} / ${formatTokenCount(usage.contextLimit)}`
+ usage.contextLimit === null
+ ? `${formatTokenCount(usage.contextUsed)} tokens`
+ : `${formatTokenCount(usage.contextUsed)} / ${formatTokenCount(usage.contextLimit)}`
);
const activeWorkLabel = $derived(formatDuration(getActiveWorkMs(usage, now)));
- const rootClass = $derived(
- [
- 'session-usage-bar',
- `session-usage-variant-${variant}`,
- tone !== null ? `session-context-tone-${tone}` : ''
- ]
- .filter(Boolean)
- .join(' ')
- );
$effect(() => {
- if (variant === 'stats' && usage.activeWorkStartedAt !== null && timer === null) {
+ if (usage.activeWorkStartedAt !== null && timer === null) {
now = Date.now();
timer = setInterval(() => (now = Date.now()), 1000);
- } else if ((variant !== 'stats' || usage.activeWorkStartedAt === null) && timer !== null) {
+ } else if (usage.activeWorkStartedAt === null && timer !== null) {
clearInterval(timer);
timer = null;
}
@@ -58,37 +35,31 @@
});
-
- {#if variant === 'stats'}
-