Repository navigation
fix: OpenClaw 2026.5.12 chat protocol + Free→Paid tier auto-detect + tier-change UX + codex plugin auto-install #132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
bc3fabe
378ca7a
7368049
8236958
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useState, type MouseEvent, type ReactNode } from "react"; | ||
| import { useClawboxLogin } from "@/lib/use-clawbox-login"; | ||
| import { useT } from "@/lib/i18n"; | ||
| import { PORTAL_DASHBOARD_URL } from "@/lib/max-subscription"; | ||
| import * as kv from "@/lib/client-kv"; | ||
|
|
||
| // Announces account-tier transitions: | ||
| // - upgrade (Free → Pro → Max): celebratory modal, shown once per tier | ||
| // - downgrade to Free (paid → Free): informational "plan is now Free" modal | ||
| // - intermediate downgrade (Max → Pro): silent | ||
| // | ||
| // Backed by client-kv so a poll re-reporting the same tier doesn't reopen | ||
| // the dialog, and so a later climb back to the same paid tier doesn't | ||
| // re-celebrate something the user already saw. A real cancel + resubscribe | ||
| // flow *does* re-celebrate, because the downgrade resets the seen marker. | ||
|
|
||
| const SEEN_KEY = "clawai_tier_seen"; | ||
|
|
||
| // Tier ordering. Keep aligned with normalizeClawboxAiTier on the server | ||
| // (flash = Pro plan, pro = Max plan). | ||
| const TIER_RANK: Record<string, number> = { flash: 1, pro: 2 }; | ||
|
|
||
| function rankOf(tier: string | null | undefined): number { | ||
| if (!tier) return 0; | ||
| return TIER_RANK[tier] ?? 0; | ||
| } | ||
|
|
||
| type DialogState = | ||
| | { kind: "upgrade"; tier: "flash" | "pro" } | ||
| | { kind: "downgrade-free" }; | ||
|
|
||
| // Per-dialog content + presentation. Keyed by the upgrade tier | ||
| // ("flash" = Pro plan, "pro" = Max plan) or "free" for the downgrade. | ||
| // Translation keys are referenced literally here so the i18n | ||
| // invariant test can still discover them. | ||
| type ContentKey = "flash" | "pro" | "free"; | ||
| type Tone = "paid" | "muted"; | ||
|
|
||
| interface DialogContent { | ||
| titleId: string; | ||
| tone: Tone; | ||
| badgeKey: string; | ||
| headlineKey: string; | ||
| bodyKey: string; | ||
| } | ||
|
|
||
| const CONTENT: Record<ContentKey, DialogContent> = { | ||
| flash: { | ||
| titleId: "tier-upgrade-title", | ||
| tone: "paid", | ||
| badgeKey: "tierCelebration.proBadge", | ||
| headlineKey: "tierCelebration.proHeadline", | ||
| bodyKey: "tierCelebration.proBody", | ||
| }, | ||
| pro: { | ||
| titleId: "tier-upgrade-title", | ||
| tone: "paid", | ||
| badgeKey: "tierCelebration.maxBadge", | ||
| headlineKey: "tierCelebration.maxHeadline", | ||
| bodyKey: "tierCelebration.maxBody", | ||
| }, | ||
| free: { | ||
| titleId: "tier-downgrade-title", | ||
| tone: "muted", | ||
| badgeKey: "tierCelebration.freeBadge", | ||
| headlineKey: "tierCelebration.freeHeadline", | ||
| bodyKey: "tierCelebration.freeBody", | ||
| }, | ||
| }; | ||
|
|
||
| export default function TierUpgradeCelebration() { | ||
| const { tier, loading } = useClawboxLogin(); | ||
| const { t } = useT(); | ||
| const [dialog, setDialog] = useState<DialogState | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| if (loading) return; | ||
| const seen = kv.get(SEEN_KEY) || ""; | ||
| const currentRank = rankOf(tier); | ||
| const seenRank = rankOf(seen); | ||
|
|
||
| // Upgrade to a paid tier we haven't celebrated yet. | ||
| if (currentRank > seenRank && (tier === "flash" || tier === "pro")) { | ||
| setDialog({ kind: "upgrade", tier }); | ||
| return; | ||
| } | ||
| // Downgrade from any paid tier to Free. | ||
| if (currentRank === 0 && seenRank > 0) { | ||
| setDialog({ kind: "downgrade-free" }); | ||
| return; | ||
| } | ||
| // Intermediate downgrade (Max → Pro) or no-change tick: silently | ||
| // sync `seen` so a later climb back to the same tier doesn't re-fire | ||
| // the celebration the user already saw. | ||
| if (seen !== (tier ?? "")) kv.set(SEEN_KEY, tier ?? ""); | ||
| }, [tier, loading]); | ||
|
|
||
| if (!dialog) return null; | ||
|
|
||
| const onClose = () => { | ||
| if (dialog.kind === "upgrade") { | ||
| kv.set(SEEN_KEY, dialog.tier); | ||
| } else { | ||
| kv.set(SEEN_KEY, ""); | ||
| } | ||
| setDialog(null); | ||
| }; | ||
|
Comment on lines
+74
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift Use the shared
As per coding guidelines, 🧰 Tools🪛 ESLint[error] 97-97: Error: Calling setState synchronously within an effect can trigger cascading renders Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect). /home/jailuser/git/src/components/TierUpgradeCelebration.tsx:97:7
(react-hooks/set-state-in-effect) 🤖 Prompt for AI Agents |
||
|
|
||
| const contentKey: ContentKey = dialog.kind === "upgrade" ? dialog.tier : "free"; | ||
| const content = CONTENT[contentKey]; | ||
| const primary = dialog.kind === "upgrade" ? ( | ||
| <button | ||
| type="button" | ||
| onClick={onClose} | ||
| autoFocus | ||
| className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl btn-gradient text-sm font-medium text-white cursor-pointer w-full" | ||
| > | ||
| {t("tierCelebration.upgradeCta")} | ||
| </button> | ||
| ) : ( | ||
| <div className="flex flex-col gap-2"> | ||
| <a | ||
| href={PORTAL_DASHBOARD_URL} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| onClick={() => setTimeout(onClose, 50)} | ||
| className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl btn-gradient text-sm font-medium text-white cursor-pointer no-underline" | ||
| > | ||
| <span className="material-symbols-rounded" style={{ fontSize: 18 }}>open_in_new</span> | ||
| {t("tierCelebration.resubscribe")} | ||
| </a> | ||
| <button | ||
| type="button" | ||
| onClick={onClose} | ||
| autoFocus | ||
| className="px-4 py-2 rounded-xl text-sm text-white/60 hover:text-white/85 hover:bg-white/[0.04] cursor-pointer" | ||
| > | ||
| {t("tierCelebration.freeCta")} | ||
| </button> | ||
| </div> | ||
| ); | ||
|
|
||
| return ( | ||
| <CelebrationShell | ||
| titleId={content.titleId} | ||
| tone={content.tone} | ||
| badge={t(content.badgeKey)} | ||
| headline={t(content.headlineKey)} | ||
| body={t(content.bodyKey)} | ||
| onClose={onClose} | ||
| primary={primary} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| interface ShellProps { | ||
| titleId: string; | ||
| tone: Tone; | ||
| badge: string; | ||
| headline: string; | ||
| body: string; | ||
| primary: ReactNode; | ||
| onClose: () => void; | ||
| } | ||
|
|
||
| function CelebrationShell({ titleId, tone, badge, headline, body, primary, onClose }: ShellProps) { | ||
| const glowClass = tone === "paid" | ||
| ? "drop-shadow-[0_0_18px_rgba(217,70,239,0.6)]" | ||
| : "drop-shadow-[0_0_12px_rgba(255,255,255,0.18)]"; | ||
| const badgeClass = tone === "paid" | ||
| ? "bg-gradient-to-r from-fuchsia-500 to-pink-500 text-white shadow-[0_4px_12px_rgba(217,70,239,0.3)]" | ||
| : "bg-white/10 text-white/70 border border-white/15"; | ||
|
|
||
| return ( | ||
| <div | ||
| className="fixed inset-0 z-[100001] flex items-center justify-center bg-black/70 backdrop-blur-sm p-4" | ||
| role="dialog" | ||
| aria-modal="true" | ||
| aria-labelledby={titleId} | ||
| onClick={onClose} | ||
|
Comment on lines
+188
to
+193
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add Escape dismissal and dialog description linkage for keyboard/screen-reader completeness. ♿ Suggested accessibility patch return (
<div
className="fixed inset-0 z-[100001] flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
+ aria-describedby={`${titleId}-description`}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") onClose();
+ }}
onClick={onClose}
>
@@
- <p className="text-sm text-white/65 leading-relaxed">{body}</p>
+ <p id={`${titleId}-description`} className="text-sm text-white/65 leading-relaxed">{body}</p>As per coding guidelines, Also applies to: 215-219 🤖 Prompt for AI Agents |
||
| > | ||
| <div | ||
| onClick={(e: MouseEvent) => e.stopPropagation()} | ||
| className="w-full max-w-md rounded-2xl border border-white/10 bg-[#0f1219] p-6 shadow-2xl text-center" | ||
| > | ||
| <div className="flex flex-col items-center gap-4 mb-5"> | ||
| <img | ||
| src="/clawbox-crab.png" | ||
| alt="" | ||
| width={72} | ||
| height={72} | ||
| className={"select-none pointer-events-none " + glowClass} | ||
| /> | ||
| <span | ||
| className={ | ||
| "inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider " + | ||
| badgeClass | ||
| } | ||
| > | ||
| {badge} | ||
| </span> | ||
| <h2 id={titleId} className="text-lg font-semibold text-white"> | ||
| {headline} | ||
| </h2> | ||
| <p className="text-sm text-white/65 leading-relaxed">{body}</p> | ||
| </div> | ||
| {primary} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add defensive
isinstancecheck forauthto match existing pattern.Line 310 will raise
AttributeErrorifcfg["auth"]exists but is not a dict (e.g.,Noneor a corrupted value), since the exception handler only catchesFileNotFoundErrorandJSONDecodeError. This would silently skip the codex install, causing confusing "harness not registered" errors.The existing code at line 131 uses the defensive pattern—this should match:
Proposed fix
primary = (cfg.get("agents", {}).get("defaults", {}).get("model", {}) or {}).get("primary") or "" -profiles = cfg.get("auth", {}).get("profiles", {}) or {} +auth = cfg.get("auth") +profiles = auth.get("profiles", {}) if isinstance(auth, dict) else {} +profiles = profiles if isinstance(profiles, dict) else {} uses_codex = (As per coding guidelines: "Review for proper error handling" — the current pattern is inconsistent with line 131's defensive approach.
🤖 Prompt for AI Agents