Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/cws-assets/icon-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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) => {
Expand Down
15 changes: 15 additions & 0 deletions entrypoints/claude-ai.content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,21 @@ async function initializeMonitoring(): Promise<void> {
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 {
Expand Down
30 changes: 29 additions & 1 deletion entrypoints/sidepanel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div className="lco-dash">
Expand Down
14 changes: 14 additions & 0 deletions entrypoints/sidepanel/components/TurnTicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
className="lco-ticker"
Expand All @@ -58,6 +65,13 @@ export default function TurnTicker({ turns, maxBars = 12 }: Props): React.ReactE
/>
);
})}
{Array.from({ length: emptySlots }, (_, i) => (
<span
key={`slot-${i}`}
className="lco-ticker-slot"
aria-hidden="true"
/>
))}
</div>
{trend !== null && (
<span className={`lco-ticker-trend lco-ticker-trend--${trend.direction}`}>
Expand Down
23 changes: 19 additions & 4 deletions entrypoints/sidepanel/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Binary file modified public/icon/128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon/16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon/32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon/48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon/96.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion ui/overlay-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 25 additions & 3 deletions ui/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -677,5 +699,5 @@ export function createOverlay(): OverlayHandle {
startFreshCallback = callback;
}

return { mount, render, showNudge, hideNudge, onStartFresh };
return { mount, render, showNudge, hideNudge, onStartFresh, setSuppressed };
}
Loading