diff --git a/docs/cws-assets/icon-mark.svg b/docs/cws-assets/icon-mark.svg new file mode 100644 index 0000000..ce37fef --- /dev/null +++ b/docs/cws-assets/icon-mark.svg @@ -0,0 +1,11 @@ + + + S + diff --git a/entrypoints/background.ts b/entrypoints/background.ts index bded995..e892971 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -85,6 +85,16 @@ if (typeof chrome !== 'undefined' && chrome.sidePanel) { chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); } +// chrome.storage.session defaults to TRUSTED_CONTEXTS, which excludes content +// scripts entirely (reads return empty, onChanged never fires). The in-page +// overlay reads sidePanelVisible from session storage to decide whether to +// suppress itself, so we widen access to all contexts here. Persists across +// service-worker restarts; safe to call on every boot. +if (typeof chrome !== 'undefined' && chrome.storage?.session?.setAccessLevel) { + chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' }) + .catch((err: unknown) => console.error('[LCO-ERROR] Failed to expose session storage to content scripts:', err)); +} + // Storage Helpers /** Build the per-tab storage key for token state */ @@ -499,6 +509,24 @@ export default defineBackground({ } }); + // Side panel visibility signal. The side panel page connects on mount + // and its port disconnects when Chrome destroys the page (close pin, close + // window). We mirror the connection state into chrome.storage.session so + // the in-page overlay (claude-ai.content.ts) can suppress itself while + // the side panel is showing the same data with more detail. + // Service-worker termination also fires onDisconnect; the side panel + // reconnects on its end, which re-fires onConnect here and restores the + // flag within ~100ms. + browser.runtime.onConnect.addListener((port) => { + if (port.name !== 'side-panel') return; + browser.storage.session.set({ sidePanelVisible: true }) + .catch((err) => console.error('[LCO-ERROR] Failed to mark side panel visible:', err)); + port.onDisconnect.addListener(() => { + browser.storage.session.set({ sidePanelVisible: false }) + .catch((err) => console.error('[LCO-ERROR] Failed to mark side panel hidden:', err)); + }); + }); + // Detect when a tab navigates away from claude.ai (logout, redirect). // Full cleanup: finalize active conversation and clear all tab storage. browser.tabs.onUpdated.addListener((tabId, changeInfo) => { diff --git a/entrypoints/claude-ai.content.ts b/entrypoints/claude-ai.content.ts index 4c1455a..958cd25 100644 --- a/entrypoints/claude-ai.content.ts +++ b/entrypoints/claude-ai.content.ts @@ -752,6 +752,21 @@ async function initializeMonitoring(): Promise { const shadow = host.attachShadow({ mode: 'closed' }); overlay.mount(shadow); + // Side panel visibility: background writes sidePanelVisible to + // chrome.storage.session via its onConnect listener. Suppress the in-page + // overlay while the side panel is showing the same data, so the user + // isn't shown two competing views. Read once on startup, then watch for + // changes for the rest of the page lifetime. + browser.storage.session.get('sidePanelVisible') + .then((data) => { overlay.setSuppressed(data.sidePanelVisible === true); }) + .catch(() => { /* non-critical: default unsuppressed if read fails */ }); + browser.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'session') return; + if ('sidePanelVisible' in changes) { + overlay.setSuppressed(changes.sidePanelVisible.newValue === true); + } + }); + // "Start fresh" flow: build handoff summary, copy to clipboard, navigate to new chat. overlay.onStartFresh(async () => { try { diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index e440a6f..901a04b 100644 --- a/entrypoints/sidepanel/App.tsx +++ b/entrypoints/sidepanel/App.tsx @@ -15,7 +15,7 @@ // the budget data to explain why the section is empty. Today and History are // org-scoped historical data and remain visible at all times. -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useDashboardData } from './hooks/useDashboardData'; import Header from './components/Header'; import CollapsibleSection from './components/CollapsibleSection'; @@ -46,6 +46,34 @@ export default function App() { // toggles state and renders nothing. const [settingsOpen, setSettingsOpen] = useState(false); + // Hold a port to the background while this side panel page is alive. + // Background mirrors the connection state into chrome.storage.session as + // sidePanelVisible; the in-page overlay watches that flag and suppresses + // itself while we're showing the same data here in more detail. + // The port disconnects automatically when Chrome destroys this page + // (panel close, window close); React cleanup is a belt-and-suspenders + // disconnect for normal unmount paths. Reconnects on disconnect to ride + // through service-worker idle restarts. + useEffect(() => { + let port: chrome.runtime.Port | null = null; + let alive = true; + + const connect = () => { + if (!alive) return; + port = chrome.runtime.connect({ name: 'side-panel' }); + port.onDisconnect.addListener(() => { + if (alive) setTimeout(connect, 100); + }); + }; + + connect(); + + return () => { + alive = false; + port?.disconnect(); + }; + }, []); + if (loading) { return (
diff --git a/entrypoints/sidepanel/components/TurnTicker.tsx b/entrypoints/sidepanel/components/TurnTicker.tsx index 9424b7d..bdb91ce 100644 --- a/entrypoints/sidepanel/components/TurnTicker.tsx +++ b/entrypoints/sidepanel/components/TurnTicker.tsx @@ -38,6 +38,13 @@ export default function TurnTicker({ turns, maxBars = 12 }: Props): React.ReactE const prev = window.length >= 2 ? window[window.length - 2] : null; const trend = computeTrend(prev?.deltaUtilization ?? null, last.deltaUtilization ?? null); + // Pad the row out to maxBars with faint placeholder slots. The grid in + // dashboard.css fixes each column to 1/maxBars of the row, so this stops + // a single tracked turn (or two) from stretching to fill the entire row + // and reading as a "solid orange wall". The empty slots also hint at + // "this fills in over time" rather than leaving raw empty space. + const emptySlots = Math.max(0, maxBars - window.length); + return (
); })} + {Array.from({ length: emptySlots }, (_, i) => ( +
{trend !== null && ( diff --git a/entrypoints/sidepanel/dashboard.css b/entrypoints/sidepanel/dashboard.css index 8176570..1c6d4a8 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -550,16 +550,20 @@ body { } .lco-ticker-bars { - display: flex; - align-items: flex-end; + /* 12-column grid keeps each bar at a fixed 1/12 of the row width + regardless of how many turns have been tracked. The flex-based + earlier version made a single tracked turn stretch to fill the whole + row, which read as a solid orange wall rather than a per-turn + histogram. Grid column count must match maxBars in TurnTicker.tsx. */ + display: grid; + grid-template-columns: repeat(12, minmax(2px, 1fr)); + align-items: end; gap: 3px; flex: 1; height: 100%; } .lco-ticker-bar { - flex: 1; - min-width: 4px; min-height: 2px; background: var(--lco-accent); border-radius: 1px; @@ -570,6 +574,17 @@ body { cursor: default; } +.lco-ticker-slot { + /* Placeholder rail for turn positions that haven't been filled yet. + Renders as a faint baseline so the row reads as "this will fill in + as the conversation grows" instead of empty negative space beside + the real bars. */ + height: 2px; + background: var(--lco-border); + border-radius: 1px; + opacity: 0.6; +} + .lco-ticker-bar:focus-visible { outline: 2px solid var(--lco-accent); outline-offset: 1px; diff --git a/package.json b/package.json index 951f9e9..21dd295 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "saar", "description": "Saar: real-time AI usage tracker for Claude", "private": true, - "version": "1.0.0", + "version": "1.0.1", "type": "module", "engines": { "node": ">=20.0.0" diff --git a/public/icon/128.png b/public/icon/128.png index 9e35d13..3e42eb9 100644 Binary files a/public/icon/128.png and b/public/icon/128.png differ diff --git a/public/icon/16.png b/public/icon/16.png index cd09f8c..19aaae4 100644 Binary files a/public/icon/16.png and b/public/icon/16.png differ diff --git a/public/icon/32.png b/public/icon/32.png index f51ce1b..0ff9105 100644 Binary files a/public/icon/32.png and b/public/icon/32.png differ diff --git a/public/icon/48.png b/public/icon/48.png index cb7a449..a3fe9f3 100644 Binary files a/public/icon/48.png and b/public/icon/48.png differ diff --git a/public/icon/96.png b/public/icon/96.png index c28ad52..ff99b99 100644 Binary files a/public/icon/96.png and b/public/icon/96.png differ diff --git a/ui/overlay-styles.ts b/ui/overlay-styles.ts index ce2f177..98d071e 100644 --- a/ui/overlay-styles.ts +++ b/ui/overlay-styles.ts @@ -83,7 +83,11 @@ export const OVERLAY_CSS = ` .lco-widget { position: fixed; - bottom: 88px; + /* 88px sat at the level of claude.ai's composer textarea; 16px puts the + widget in the empty viewport corner below the composer footer text. + The "two surfaces showing the same data" overlap is handled separately + by setSuppressed() in ui/overlay.ts (driven by side panel state). */ + bottom: 16px; right: 16px; z-index: 2147483647; min-width: 210px; diff --git a/ui/overlay.ts b/ui/overlay.ts index 459dbad..a03955f 100644 --- a/ui/overlay.ts +++ b/ui/overlay.ts @@ -19,6 +19,10 @@ export interface OverlayHandle { hideNudge(): void; /** Register the callback for the "Start fresh" button. Called once at setup. */ onStartFresh(callback: () => void): void; + /** Force-hide the overlay regardless of render state. Used when the side + * panel is open: it surfaces the same data with more detail, so the + * in-page widget steps aside to avoid duplicated views and overlap. */ + setSuppressed(value: boolean): void; } function fmt(n: number): string { @@ -138,6 +142,21 @@ export function createOverlay(): OverlayHandle { let elWeeklyLabel: HTMLElement | null = null; let elWeeklyEta: HTMLElement | null = null; + // Visibility state. shouldShow is a one-way latch flipped true by render() + // on first data arrival; suppressed is toggled externally by setSuppressed + // to defer to the side panel when it's open. applyVisibility reconciles + // both into the actual display style. + let shouldShow = false; + let suppressed = false; + function applyVisibility(): void { + if (!overlayWidget) return; + overlayWidget.style.display = (shouldShow && !suppressed) ? '' : 'none'; + } + function setSuppressed(value: boolean): void { + suppressed = value; + applyVisibility(); + } + function mount(shadow: ShadowRoot): void { const style = document.createElement('style'); style.textContent = OVERLAY_CSS; @@ -424,8 +443,11 @@ export function createOverlay(): OverlayHandle { if (!overlayWidget) return; // Reveal widget on first data arrival or when draft estimate is available. - if ((state.lastRequest !== null || state.draftEstimate !== null) && overlayWidget.style.display === 'none') { - overlayWidget.style.display = ''; + // Routed through applyVisibility so suppressed (side panel open) wins + // over the data-arrival latch. + if (state.lastRequest !== null || state.draftEstimate !== null) { + shouldShow = true; + applyVisibility(); } // Draft estimate: pre-submit cost preview. @@ -677,5 +699,5 @@ export function createOverlay(): OverlayHandle { startFreshCallback = callback; } - return { mount, render, showNudge, hideNudge, onStartFresh }; + return { mount, render, showNudge, hideNudge, onStartFresh, setSuppressed }; }