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 @@ {session.sessionId.slice(0, 13)} {/if} +
@@ -87,8 +86,8 @@ {status.label} - {#if agentName} - {agentName} + {#if session.sessionId} + {/if} {#if profileLabel} @@ -96,9 +95,12 @@ {/if} + {#if session.usage.contextUsed !== null} + + {/if} {updatedAt} - {#if session.sessionId} - + {#if agentName} + {agentName} {/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'} -
- {#if usage.cumulativeCostUsd !== null} -
-
- {/if} +
+
+ {#if usage.contextUsed !== null} +
+
+ {/if} -
-
- {:else} -
- - Context - {contextLabel} - {#if contextPercent !== null} -
- -
- {Math.round(contextPercent)}% + {/if} + +
+
- {/if} +
diff --git a/src/lib/components/session/SessionUsageBar.test.ts b/src/lib/components/session/SessionUsageBar.test.ts index 011606a..7775a03 100644 --- a/src/lib/components/session/SessionUsageBar.test.ts +++ b/src/lib/components/session/SessionUsageBar.test.ts @@ -10,48 +10,21 @@ const usage = { activeWorkStartedAt: null }; -function usageBarRoot(): HTMLElement { - const root = document.querySelector('.session-usage-bar'); - expect(root).not.toBeNull(); - return root!; -} - afterEach(cleanup); describe('SessionUsageBar', () => { - it('renders the full-width context row for the header variant', () => { - render(SessionUsageBar, { usage, variant: 'context', tone: 1 }); + it('renders context, cost, and active work as text stats without a meter', () => { + render(SessionUsageBar, { usage }); expect(screen.getByText('48k / 200k')).toBeTruthy(); - expect(screen.getByLabelText('Context window 24% used')).toBeTruthy(); - expect(screen.getByText('24%')).toBeTruthy(); - expect(screen.queryByText('Cost')).toBeNull(); - expect(screen.queryByText('1m 2s')).toBeNull(); - - const root = usageBarRoot(); - expect(root.classList.contains('session-context-tone-1')).toBe(true); - expect(root.classList.contains('session-usage-variant-context')).toBe(true); - expect(root.querySelector('.session-usage-meter')).not.toBeNull(); - }); - - it('renders cost and active work without a context meter in the popover variant', () => { - render(SessionUsageBar, { usage, variant: 'stats' }); - + expect(screen.getByText('Context').closest('.session-usage-stat')?.querySelector('svg')).not.toBeNull(); expect(screen.getByText('$0.13')).toBeTruthy(); expect(screen.getByText('1m 2s')).toBeTruthy(); - expect(screen.queryByText('Context')).toBeNull(); - expect(screen.queryByText('48k / 200k')).toBeNull(); - expect(screen.queryByLabelText(/Context window/)).toBeNull(); + expect(screen.queryByRole('meter')).toBeNull(); expect(document.querySelector('.session-usage-meter')).toBeNull(); }); - it('keeps the default accent when no tone is provided', () => { - render(SessionUsageBar, { usage, variant: 'context' }); - - expect(usageBarRoot().className).not.toContain('session-context-tone'); - }); - - it('uses clear unavailable values before the first usage update', () => { + it('omits the context stat before the first usage update', () => { render(SessionUsageBar, { usage: { contextUsed: null, @@ -59,20 +32,17 @@ describe('SessionUsageBar', () => { cumulativeCostUsd: null, activeWorkMs: 0, activeWorkStartedAt: null - }, - variant: 'context' + } }); - expect(screen.getByText('No usage yet')).toBeTruthy(); - expect(screen.queryByText(/%$/)).toBeNull(); - expect(screen.queryByLabelText(/Context window/)).toBeNull(); - expect(document.querySelector('.session-usage-meter')).toBeNull(); + expect(screen.queryByText('Context')).toBeNull(); + expect(screen.queryByText('No usage yet')).toBeNull(); + expect(screen.getByText('0s')).toBeTruthy(); }); it('shows the live indicator while prompts are processing', () => { render(SessionUsageBar, { - usage: { ...usage, activeWorkStartedAt: Date.now() - 5_000 }, - variant: 'stats' + usage: { ...usage, activeWorkStartedAt: Date.now() - 5_000 } }); expect(screen.getByLabelText('Active now')).toBeTruthy(); diff --git a/src/lib/domain/session-usage.test.ts b/src/lib/domain/session-usage.test.ts index 12f0538..ac03c7c 100644 --- a/src/lib/domain/session-usage.test.ts +++ b/src/lib/domain/session-usage.test.ts @@ -5,7 +5,9 @@ import { formatDuration, formatTokenCount, getActiveWorkMs, - getContextPercent + getContextPercent, + getContextPressure, + getContextRemaining } from './session-usage'; const usage: SessionUsageStats = { @@ -29,4 +31,16 @@ describe('session usage formatting', () => { it('adds an open live span to completed active time', () => { expect(getActiveWorkMs({ ...usage, activeWorkStartedAt: 10_000 }, 14_500)).toBe(34_500); }); + + it('keeps context pressure quiet until the window is actually tight', () => { + expect(getContextPressure(null)).toBe('calm'); + expect(getContextPressure(69.9)).toBe('calm'); + expect(getContextPressure(70)).toBe('warm'); + expect(getContextPressure(84.9)).toBe('warm'); + expect(getContextPressure(85)).toBe('tight'); + expect(getContextPressure(94.9)).toBe('tight'); + expect(getContextPressure(95)).toBe('full'); + expect(getContextRemaining(usage)).toBe(152_000); + expect(getContextRemaining({ ...usage, contextLimit: null })).toBeNull(); + }); }); diff --git a/src/lib/domain/session-usage.ts b/src/lib/domain/session-usage.ts index 7d92375..324356f 100644 --- a/src/lib/domain/session-usage.ts +++ b/src/lib/domain/session-usage.ts @@ -5,6 +5,21 @@ export function getContextPercent(usage: SessionUsageStats): number | null { return Math.min(100, Math.max(0, (usage.contextUsed / usage.contextLimit) * 100)); } +export type ContextPressure = 'calm' | 'warm' | 'tight' | 'full'; + +/** The reading stays quiet until the window is actually under pressure. */ +export function getContextPressure(percent: number | null): ContextPressure { + if (percent === null || percent < 70) return 'calm'; + if (percent < 85) return 'warm'; + if (percent < 95) return 'tight'; + return 'full'; +} + +export function getContextRemaining(usage: SessionUsageStats): number | null { + if (usage.contextUsed === null || usage.contextLimit === null) return null; + return Math.max(0, usage.contextLimit - usage.contextUsed); +} + export function getActiveWorkMs(usage: SessionUsageStats, now = Date.now()): number { const liveMs = usage.activeWorkStartedAt === null ? 0 : Math.max(0, now - usage.activeWorkStartedAt); return usage.activeWorkMs + liveMs; diff --git a/src/routes/sessions/[agentId]/[sessionId]/+page.svelte b/src/routes/sessions/[agentId]/[sessionId]/+page.svelte index 1260788..004fc41 100644 --- a/src/routes/sessions/[agentId]/[sessionId]/+page.svelte +++ b/src/routes/sessions/[agentId]/[sessionId]/+page.svelte @@ -24,7 +24,6 @@ } from '$lib/domain/session-scroll'; import { buildSessionConversation } from '$lib/domain/session-conversation'; import { formatSessionTimestamp, getSessionById, getSessionWorkspaceName } from '$lib/domain/sessions'; - import { getSessionContextTone } from '$lib/domain/session-mode-tone'; import { findReasoningConfigOption, getConfigOptionChoices, @@ -58,9 +57,6 @@ }); const showAgentBadges = $derived(agentsStore.connectedAgents.length > 1); const pendingElicitations = $derived(inboxStore.pendingElicitationsForSession(agentId, sessionId)); - // Confirmed active mode drives the context meter tone; agent mode ids stay opaque. - const sessionModeId = $derived(agentId && sessionId ? agentsStore.getSessionModeId(agentId, sessionId) : ''); - const contextTone = $derived(getSessionContextTone(agentsStore.activeSession.configOptions, sessionModeId)); // Reference clock for the relative "updated" label so it advances on its own // instead of only when session data changes. let relativeTimeNow = $state(Date.now()); @@ -529,7 +525,6 @@ profileLabel={sessionProfileLabel} updatedAt={selectedSession ? formatSessionTimestamp(selectedSession.updatedAt, relativeTimeNow) : 'Not loaded'} summaryStatus={selectedSession?.status ?? 'idle'} - contextTone={contextTone} debugLabel={debugEventsTooltip} showDebug={chatPreferencesStore.developerMode} forkPending={agentsStore.forkPending}