Skip to content
Open
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
35 changes: 34 additions & 1 deletion apps/web/__tests__/unit/prettify-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,42 @@ describe("prettifyModel", () => {
});
});

describe("Gemini models", () => {
it("prettifies gemini-3.1-pro-preview", () => {
expect(prettifyModel("gemini-3.1-pro-preview")).toBe("Gemini 3.1 Pro");
});

it("prettifies gemini-3.1-flash-lite-preview", () => {
expect(prettifyModel("gemini-3.1-flash-lite-preview")).toBe("Gemini 3.1 Flash Lite");
});

it("prettifies gemini-3-flash-preview", () => {
expect(prettifyModel("gemini-3-flash-preview")).toBe("Gemini 3 Flash");
});

it("prettifies gemini-2.5-pro", () => {
expect(prettifyModel("gemini-2.5-pro")).toBe("Gemini 2.5 Pro");
});

it("prettifies gemini-2.5-flash", () => {
expect(prettifyModel("gemini-2.5-flash")).toBe("Gemini 2.5 Flash");
});

it("prettifies gemini-2.5-flash-lite", () => {
expect(prettifyModel("gemini-2.5-flash-lite")).toBe("Gemini 2.5 Flash Lite");
});

it("prettifies gemini-2.0-flash", () => {
expect(prettifyModel("gemini-2.0-flash")).toBe("Gemini 2.0 Flash");
});

it("prettifies gemini-2.0-flash-lite", () => {
expect(prettifyModel("gemini-2.0-flash-lite")).toBe("Gemini 2.0 Flash Lite");
});
});

describe("unknown models", () => {
it("returns the model name as-is for unrecognized models", () => {
expect(prettifyModel("gemini-2.0-flash")).toBe("gemini-2.0-flash");
expect(prettifyModel("qwen-2.5-coder")).toBe("qwen-2.5-coder");
expect(prettifyModel("mistral-large")).toBe("mistral-large");
});
Expand Down
21 changes: 21 additions & 0 deletions apps/web/components/app/feed/ActivityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ export function prettifyModel(model: string): string {
}
if (/^o4/i.test(normalized)) return "o4";
if (/^o3/i.test(normalized)) return "o3";
// Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro"
if (/^gemini-/i.test(normalized)) {
return normalized
.replace(/^gemini-/i, "Gemini ")
.replace(/-preview.*$/, "")
.replace(/-exp.*$/, "")
.replace(/-/g, " ")
.replace(/\s+/g, " ")
.split(" ")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
// Legacy: broader Claude matching
if (normalized.includes("opus")) return "Claude Opus";
if (normalized.includes("sonnet")) return "Claude Sonnet";
Expand Down Expand Up @@ -171,6 +183,15 @@ function modelColor(name: string): string {
if (/GPT-4o/.test(name)) return "#4C78A8";
if (/^o3/i.test(name)) return "#3B82F6";
if (/^o4/i.test(name)) return "#6366F1";
if (/Gemini 3\.1 Pro/.test(name)) return "#4285F4";
if (/Gemini 3\.1 Flash Lite/.test(name)) return "#00ACC1";
if (/Gemini 3 Flash/.test(name)) return "#009688";
if (/Gemini 2\.5 Pro/.test(name)) return "#3F51B5";
if (/Gemini 2\.5 Flash Lite/.test(name)) return "#8BC34A";
if (/Gemini 2\.5 Flash/.test(name)) return "#34A853";
if (/Gemini 2\.0 Flash Lite/.test(name)) return "#FF8F00";
if (/Gemini 2\.0 Flash/.test(name)) return "#FBBC05";
if (/Gemini/i.test(name)) return "#3F51B5"; // Google indigo fallback
Comment on lines +186 to +194

@coderabbitai coderabbitai Bot Apr 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace hardcoded Gemini hex colors with theme tokens.

These new raw hex values in app UI logic break the web theming rule and make palette governance harder. Please source these from the shared globals.css theme token model (or a token-backed color map) instead of embedding literals here.

As per coding guidelines: apps/web/**/*.{css,tsx,ts}: “Only use colors from globals.css @theme block — no purple gradients, glow effects, or styles not already in the codebase”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/app/feed/ActivityCard.tsx` around lines 186 - 194,
Replace the hardcoded hex literals in ActivityCard.tsx with theme tokens from
globals.css: create or use a token-backed color map (e.g., GEMINI_COLOR_MAP)
keyed by the same model patterns and return the mapped token instead of raw hex
in the code that inspects the variable name (the existing if-chain that tests
/Gemini.../ and returns colors). Import the shared token values (or a helper
like getThemeColor) and replace each return "#xxxxxx" with the corresponding
theme token reference; fall back to the existing Gemini indigo token for
unmatched names. Ensure the mapping keys correspond to the same regex checks (or
replace the if-chain with a single resolver function like getGeminiColor(name)
that uses GEMINI_COLOR_MAP) so ActivityCard preserves behavior but uses theme
tokens.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional and consistent with the existing pattern. The modelColor() function is a data-visualization palette that maps brand-representative hex colors to each AI model for the usage breakdown bar. All models in this function use hardcoded hex values — Claude (#DF561F, #F08A5D, #F7B267), GPT (#2A9D8F, #4C78A8), o3/o4 (#3B82F6, #6366F1), and the 6-color fallback palette on line 196. The Gemini entries follow the exact same established pattern.

The theme tokens in globals.css @theme inline define UI chrome colors (background, foreground, accent, border, muted, subtle, etc.) — there are no model-specific color tokens. Moving only the Gemini colors into the theme while leaving Claude/GPT/o3/o4 hardcoded would be inconsistent. Additionally, the function uses regex-based matching (not exact string equality), so a simple constant map wouldn't be a drop-in replacement without refactoring the matching logic.

No change needed here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nihalnihalani, that's a clear and well-reasoned explanation — you're right. The modelColor() function is a data-visualization palette where hardcoded hex is the established pattern across all model families, and the globals.css theme tokens cover only UI chrome (not model brand colors). Flagging only the Gemini additions while the same approach is used throughout was inconsistent. Happy to stand down on this one.

(ᓀ‸ᓂ)


✏️ Learnings added
Learnt from: nihalnihalani
Repo: ohong/straude PR: 77
File: apps/web/components/app/feed/ActivityCard.tsx:186-194
Timestamp: 2026-04-04T23:07:34.823Z
Learning: In `apps/web/components/app/feed/ActivityCard.tsx`, the `modelColor()` function intentionally uses hardcoded hex color literals for data-visualization purposes (the model usage breakdown bar). This is the established pattern for ALL model families — Claude (`#DF561F`, `#F08A5D`, `#F7B267`), GPT (`#2A9D8F`, `#4C78A8`), o3/o4 (`#3B82F6`, `#6366F1`), and the 6-color hash-based fallback palette. The `globals.css` theme block only defines UI chrome tokens (background, foreground, accent, border, muted, subtle), not model-specific palette colors. Do not flag hardcoded hex values in this function as a theming violation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: ohong/straude PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-12T19:15:44.038Z
Learning: Applies to apps/web/**/*.{css,tsx,ts} : Use accent color `#DF561F` only for interactive elements and emphasis

Learnt from: CR
Repo: ohong/straude PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-12T19:15:44.038Z
Learning: Applies to apps/web/**/*.{css,tsx,ts} : Only use colors from `globals.css` `theme` block — no purple gradients, glow effects, or styles not already in the codebase


const palette = ["#EF4444", "#F59E0B", "#10B981", "#06B6D4", "#8B5CF6", "#EC4899"];
return palette[hashString(name) % palette.length]!;
Expand Down
12 changes: 12 additions & 0 deletions apps/web/lib/open-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,18 @@ export function prettifyModel(model: string): string {
}
if (/^o4/i.test(normalized)) return "o4";
if (/^o3/i.test(normalized)) return "o3";
// Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro"
if (/^gemini-/i.test(normalized)) {
return normalized
.replace(/^gemini-/i, "Gemini ")
.replace(/-preview.*$/, "")
.replace(/-exp.*$/, "")
.replace(/-/g, " ")
.replace(/\s+/g, " ")
.split(" ")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
if (normalized.includes("opus")) return "Claude Opus";
if (normalized.includes("sonnet")) return "Claude Sonnet";
if (normalized.includes("haiku")) return "Claude Haiku";
Expand Down
12 changes: 12 additions & 0 deletions apps/web/lib/share-assets/post-card-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ function prettifyModel(model: string): string {
}
if (/^o4/i.test(normalized)) return "o4";
if (/^o3/i.test(normalized)) return "o3";
// Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro"
if (/^gemini-/i.test(normalized)) {
return normalized
.replace(/^gemini-/i, "Gemini ")
.replace(/-preview.*$/, "")
.replace(/-exp.*$/, "")
.replace(/-/g, " ")
.replace(/\s+/g, " ")
.split(" ")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
return normalized;
}

Expand Down
12 changes: 12 additions & 0 deletions apps/web/lib/utils/post-share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ function prettifyModel(model: string): string {

if (/^o4/i.test(normalized)) return "o4";
if (/^o3/i.test(normalized)) return "o3";
// Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro"
if (/^gemini-/i.test(normalized)) {
return normalized
.replace(/^gemini-/i, "Gemini ")
.replace(/-preview.*$/, "")
.replace(/-exp.*$/, "")
.replace(/-/g, " ")
.replace(/\s+/g, " ")
.split(" ")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
return normalized;
}

Expand Down
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

## Unreleased

### Fixed

- **Gemini model display across all prettifyModel copies.** Three copies of `prettifyModel` (in `post-share.ts`, `post-card-image.tsx`, `open-stats.ts`) were missing Gemini support entirely. Added the Gemini block to all three. Also improved suffix stripping in all 5 copies: `-preview` and `-exp` suffixes (including dated variants like `-preview-05-06` and `-exp-03-25`) are now stripped so model IDs like `gemini-2.5-pro-exp-03-25` display as "Gemini 2.5 Pro".
- **Web modelColor() now includes Gemini colors.** Added 8 Gemini model color entries and a family fallback (#3F51B5 Google indigo) to the `modelColor()` function in `ActivityCard.tsx`, matching the CLI theme palette.

### Added

- **Gemini CLI usage tracking via gemistat.** The CLI now collects Gemini CLI usage data alongside Claude Code and Codex. Runs `gemistat daily --json` in parallel with ccusage and @ccusage/codex, merges all three sources by date, and submits combined stats. Silent failure — users without Gemini CLI or gemistat are unaffected. Gemini models (Gemini 3.1 Pro, 2.5 Pro/Flash, etc.) get dedicated colors in the CLI scorecard palette and pretty names in feed cards.
- **User Signups bar chart on admin dashboard.** Shows daily new user signups with 7D/30D/All time range selector. Reuses the existing `admin_growth_metrics` RPC — no new migration needed.
- **GitHub README scorecard embed.** New SVG endpoint at `/api/embed/<username>/svg` renders a scorecard matching the `/stats` profile card design — warm gradient, 365-day heatmap with month/day labels and legend, streak, output tokens, active days, and model. Supports `?theme=light|dark` and `?compact=1`. Uses the bolt logo.
- **Cmd+Enter to send DMs.** The message composer in `/messages` now supports `Cmd+Enter` (Mac) / `Ctrl+Enter` to send. The send button shows the shortcut hint ("Send ⌘↵") matching the prompt submission widget style.
Expand Down
14 changes: 14 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Architecture & Design Decisions

## Gemini CLI Integration: gemistat Subprocess, Not Direct Telemetry Parsing (2026-04-04)

**Decision:** Integrate Gemini CLI usage tracking by running `gemistat` (by ryoppippi, same author as ccusage) as a subprocess, mirroring the existing ccusage/codex pattern. Silent failure for users without Gemini CLI.

**Why:**
- **Same author, same conventions.** gemistat outputs `{ daily: [...], totals: {...} }` with field names matching ccusage (`modelsUsed`, `totalCost`, `cacheReadTokens`). This means the parser is trivial — no format translation needed.
- **Keeps the CLI thin.** gemistat handles telemetry parsing, pricing lookup (via LiteLLM), and daily aggregation. Replicating this in Straude would mean maintaining a Gemini pricing table and OpenTelemetry parser.
- **Proven pattern.** ccusage (fatal errors) and codex (silent errors) are battle-tested. Gemini follows the codex pattern — silent failure, so existing users are never affected.

**Alternatives considered:**
1. **Parse `~/.gemini/telemetry.log` directly** — Full control, no dependency, but requires maintaining a pricing table and OpenTelemetry parser. Telemetry must still be enabled manually.
2. **Parse chat session files (`~/.gemini/tmp/*/chats/*.json`)** — No telemetry enablement needed, but token data is less structured and harder to aggregate reliably.
3. **Hybrid (gemistat with fallback to direct parsing)** — More robust but doubles the code surface for marginal benefit.

## Legacy-to-Device Usage Backfill Strategy (2026-03-28)

**Decision:** When the multi-device push path encounters a `daily_usage` row with zero `device_usage` backing rows, automatically insert a sentinel `device_usage` row (device_id `00000000-...`, device_name "legacy") before aggregation.
Expand Down
Loading