Skip to content

feat(go-plan): 三个营销点位(Banner/弹窗/右上角入口)+ 埋点与归因 - #7093

Open
open-design-crew[bot] wants to merge 7 commits into
mainfrom
feat/go-plan-marketing-touchpoints
Open

feat(go-plan): 三个营销点位(Banner/弹窗/右上角入口)+ 埋点与归因#7093
open-design-crew[bot] wants to merge 7 commits into
mainfrom
feat/go-plan-marketing-touchpoints

Conversation

@open-design-crew

Copy link
Copy Markdown
Contributor

Go plan 一期 · 三个营销点位(含埋点与归因)

活动窗口 2026-08-20 20:00 → 09-03 20:00(UTC+8),三个点位窗口外整体下线;仅未付费用户可见(audience 类型层钉死 unpaid,付费用户不挂载组件、零事件)。

点位

  1. 官网首页 Bannergo-banner.astro,11 语言)
    • 时间/关闭/受众三道门禁;评审预览 ?campaign=go-plan(只放开时间与关闭,不放开受众)
    • CTA 生成唯一 entry_id,经 od_* 参数 → Pricing 页转发 → Cloud(T-03/T-04)
    • 展示期间用 CSS 类压制同页 DS Banner(GMK-010「未付费用户 DS 触点替换为 Go」;重叠期 8/20–8/27 两条 fixed 通栏会叠放)
  2. 工作台首页弹窗GoUpsellModal,19 语言)——每账号整个活动期仅弹一次,任一关闭方式均计已展示
  3. 工作台右上角入口GoNavEntry,药丸+箭头,对齐现网 DS 角标实测规格)——挂在 WorkspaceTabsBar 新增的 trailingSlot(dock portal 之外、no-drag

埋点

  • 每点位 surface_view + ui_clickcampaign_id=go_plan_launch;入口来源 home_go_upsell_modal / home_go_badge 独立可拆
  • daemon 三个 fail-closed 白名单已同步AMR_ENTRY_SOURCESAMR_ENTRY_CAMPAIGN_IDS、page 映射)——缺一个会整条 entry 静默作废
  • 已回填飞书埋点文档 MUu2Au 235–240 行

验收

  • contracts / daemon / web typecheck 通过;contracts 291 用例、web 628 文件 / 6592 用例通过(App.update-dialog 的 EntryNavRail mock 补了 workspaceUpgradeUrl 导出)
  • 官网构建 6420 页通过(改动前基线)

注意

  • Go 暂不可结账:无 Stripe 目录;配套 vela PR(归因白名单 + 控制台 Go 档口径)需同期合入,否则支付归因断链
  • 需求文档 GMK-013「角标常驻」已被产品决策覆盖(2026-08-18:到期一并下线),文档待同步

🤖 Generated with Claude Code

Go plan launch (2026-08-20 20:00 – 09-03 20:00, UTC+8), unpaid users only:

1. Landing home banner (go-banner.astro, 11 locales)
   - campaign-window + dismiss + audience gates; review preview via
     ?campaign=go-plan (relaxes time/dismiss gates only, never audience)
   - CTA mints a unique entry_id, forwarded on the URL as od_* params;
     the pricing page passes them through to Cloud (T-03/T-04)
   - while visible it suppresses the DeepSeek home banner via CSS class,
     per "unpaid users see Go instead of DS" (GMK-010); overlap window
     8/20–8/27 would otherwise stack two fixed banners

2. Workbench home upsell modal (GoUpsellModal, 19 locales)
   - once per account for the whole campaign; any dismissal counts as seen

3. Workbench top-right entry (GoNavEntry, pill + arrow)
   - mounted in WorkspaceTabsBar's new trailingSlot (outside the dock
     portal, no-drag), not the left rail: the rail collapses
   - campaign-window gated like the others (product ruling 2026-08-18,
     supersedes GMK-013's always-on wording)

Tracking: surface_view + ui_click per touchpoint, campaign_id=
go_plan_launch, audience pinned to 'unpaid' at the type level; entry
sources home_go_upsell_modal / home_go_badge keep the two conversions
separable. Daemon fail-closed whitelists (AMR_ENTRY_SOURCES,
AMR_ENTRY_CAMPAIGN_IDS) extended — a source or campaign missing there
voids the whole attributed entry silently.

Backfilled to the Feishu tracking doc (MUu2Au rows 235-240).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lefarcen
lefarcen requested a review from nettee August 18, 2026 16:46
@lefarcen lefarcen added size/XL PR changes 700-1500 lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/feature New feature needs-validation Runtime change detected; needs human or /explore agent validation. labels Aug 18, 2026
@lefarcen

Copy link
Copy Markdown
Contributor

🧪 This PR touches user-facing flows, so it needs a manual QA pass before merge — please hold off self-merging until QA has signed off.

Also, could you update the PR description with Why, What users will see, Surface area, and Validation? The implementation summary is detailed, but those fields make pool review and release triage much faster.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Visual regression review

Head: 635e80f · Base: b800b91

0 changed · 49 unchanged · 0 new without baseline · 0 failed

Unchanged cases
Case Main PR Diff
visual-avatar-local-agent-list
0 px (0.00%)
main pr diff
visual-avatar-local-agent-list-panel
0 px (0.00%)
main pr diff
visual-avatar-menu
0 px (0.00%)
main pr diff
visual-avatar-menu-panel
0 px (0.00%)
main pr diff
visual-avatar-open-design-model-picker
0 px (0.00%)
main pr diff
visual-critical-settings
0 px (0.00%)
main pr diff
visual-critical-workspace
0 px (0.00%)
main pr diff
visual-critical-workspace-preview
0 px (0.00%)
main pr diff
visual-design-system-detail
0 px (0.00%)
main pr diff
visual-design-systems
0 px (0.00%)
main pr diff
visual-home
0 px (0.00%)
main pr diff
visual-home-catalog
0 px (0.00%)
main pr diff
visual-home-context-picker
0 px (0.00%)
main pr diff
visual-home-context-picker-popover
0 px (0.00%)
main pr diff
visual-home-plugin-filter
0 px (0.00%)
main pr diff
visual-home-plugin-use-staged
0 px (0.00%)
main pr diff
visual-home-plugin-use-with-query
0 px (0.00%)
main pr diff
visual-home-staged-attachment
0 px (0.00%)
main pr diff
visual-integrations
0 px (0.00%)
main pr diff
visual-integrations-mcp
0 px (0.00%)
main pr diff

Visual diff is advisory only and does not block merging.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@app/open-design-crew

I found blocking correctness issues in the new Go touchpoints. A targeted parse check confirms that the Pricing inline script is syntactically invalid, and the landing/web changes also contain fail-open audience gates, an unregistered attribution source, a campaign overwrite, modal state leakage, and ambient-workspace routing. Please address the inline findings before merge.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

'od_campaign_id',
'od_conversion_source',
];
const inboundParams = new URLSearchParams(window.location.search);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This new declaration duplicates the existing const inboundParams at line 727 in the same inline script. The generated Pricing script now fails to parse with Identifier 'inboundParams' has already been declared, so none of the CTA wiring (including this forwarding) can run. Remove the second declaration and reuse the original variable; add a build-time syntax check for this inline block.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

'od_entry_id',
'od_entry_source',
'od_entry_at',
'od_campaign_id',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Including od_campaign_id in this raw-forward list reintroduces the campaign value that the existing code explicitly removes. A Go banner arrives with od_campaign_id=go_plan_launch, but during the 8/20–8/27 overlap the existing Pricing click handler passes deepseek_v4_pro to __odRecordCampaignEntry; __odAttributedUrl then overwrites the query value. The Go CTA is therefore credited as DeepSeek. Make the Pricing conversion logic preserve an explicit Go entry (or select the campaign by inbound source) while still stripping stale campaign ids outside the campaign window, and cover the overlap in a test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

var url = new URL(cta.getAttribute('href'), window.location.origin);
url.searchParams.set('od_origin', 'open_design');
url.searchParams.set('od_entry_id', entryId);
url.searchParams.set('od_entry_source', 'landing_go_banner');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This value is not a valid AMR entry source. TrackingAmrEntrySource, the web ENTRY_PAGE_BY_SOURCE, and the daemon AMR_ENTRY_SOURCES/page map only contain home_go_upsell_modal and home_go_badge; parseVelaLoginAttribution fail-closes unknown sources. The banner-to-Pricing-to-Cloud handoff generated here is therefore discarded, so the T-03/T-04 attribution chain never reaches Vela. Add landing_go_banner consistently to the contracts, web map, daemon allowlist/page map, and tests, or use an existing allowed source.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

.then((r) => (r.ok ? r.json() : null))
.then((data) => {
const user = data && data.user ? data.user : null;
if (isUnpaid(user) || reviewPreview) reveal();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This reveal condition fails the stated unpaid-only audience gate. reviewPreview is ORed into it, so paid users using ?campaign=go-plan are revealed even though preview is supposed to bypass only time/close; any non-2xx session response is converted to null, and isUnpaid(null) treats it as unpaid; and the helper's negative paid allowlist treats unknown or future paid tiers as unpaid. These are fail-open paths that can show the banner to paid users and emit forbidden events. Keep explicit session states: only known signed-out/free/none reveals, while HTTP or network errors and unknown plans stay hidden; preview must not bypass audience.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

const [open, setOpen] = useState(false);

useEffect(() => {
if (!isHomeActive || !isUnpaid) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When the Home view is left or billing resolves to a paid audience, this effect returns without clearing open. EntryShell stays mounted while switching its entry views, and the dialog is portaled to document.body, so a modal opened on Home remains over Settings or Projects after the gate no longer passes. Clear open whenever !isHomeActive || !isUnpaid (without marking it seen on navigation), and add a Home-to-other-view transition test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

}

/** localStorage key recording that this account has seen the campaign modal. */
export const GO_UPSELL_SEEN_KEY = 'od.goUpsell.seen';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment and the PR requirement say this is once per account, but this fixed localStorage key is browser-global. Dismissing account A writes od.goUpsell.seen=1; signing out and entering account B in the same browser then suppresses B's once-per-account modal. Scope the key by a stable account or workspace identity (or persist the state server-side), and re-key/reset it on identity transitions.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/App.tsx
identityScopeKey={workspaceTabsIdentityScopeKey}
trailingSlot={(
<GoNavEntry
upgradeUrl={workspaceUpgradeUrl(workspaceContext, workspaceBilling)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This entry is mounted for project routes, but both its URL and audience gate use the ambient workspaceContext and workspaceBilling. The surrounding code separately resolves activeProjectWorkspaceContext as authoritative for the open project and passes it to the account cluster; ambient context can be another workspace while a deep-linked project is open. A paid project can therefore show Go for a free ambient workspace, or send the user to the wrong workspace's upgrade URL. Derive GoNavEntry from the project-scoped context and billing when route.kind === 'project' (or omit it off Home), rather than using ambient scope.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Per the requirement doc's「客户端进行排队提示」section (2026-08-19):

- The existing model_window_limit card (「高峰期繁忙,请在 {retryAt} 后
  尝试(本次请求未扣费)」) now differs by plan tier: Plus/Pro/Max get
  the trailing "you can switch to another model to keep going" hint
  (new modelWindowLimitSwitch* keys, 19 locales, generated from each
  locale's existing base string so the two variants cannot drift);
  Go and unknown tiers keep the base copy — promising a fallback that
  does not exist is worse than not naming one.
- The card's button is now 升级套餐 (upgrade) for every tier, replacing
  the old Retry — retrying a rolling window reproduces the same wall.
- Click tracking: area=go_limit_card, element=upgrade, with
  tier_has_fallback as THE breakdown dimension (a Go upgrade and a Max
  upgrade mean opposite things). The upgrade routes through its own
  entry source chat_go_limit_upgrade so the conversion is separable
  from the generic chat_error_upgrade bucket. Exposure reuses the
  existing run_failed_toast surface_view (doc section B).
- Daemon fail-closed whitelists extended for the new source; companion
  vela whitelist change rides powerformer/vela#1671.

Backfilled to the Feishu tracking doc (MUu2Au row 241).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@app/open-design-crew

The current head has multiple blocking correctness issues: the landing Pricing script does not parse, Go attribution is rejected or overwritten in the conversion path, paid and team users can receive the campaign, and the workbench entry and modal can become stale or be scoped to the wrong workspace. I verified the live diff and the failed landing-page validation check; the inline comments below include concrete fixes and regression cases.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

'od_campaign_id',
'od_conversion_source',
];
const inboundParams = new URLSearchParams(window.location.search);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The inline Pricing script declares inboundParams at line 727 and declares it again here at line 741 in the same scope. The landing-page validation check already fails with a duplicate block-scoped variable, so the script cannot parse and the new attribution and CTA path never runs. Remove this redeclaration and reuse the first binding, then keep a script syntax or typecheck fixture for the page.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

const scopedBillingPlanUrl = () => {
const target = new URL(billingPlanUrl);
if (inboundWorkspaceId) target.searchParams.set('workspaceId', inboundWorkspaceId);
for (const key of OD_ATTRIBUTION_PARAMS) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This forwarding loop now preserves inbound od_campaign_id, but during the overlap window the same CTA path at lines 1103-1114 calls __odRecordCampaignEntry with deepseek_v4_pro and __odAttributedUrl overwrites that query value. A visitor arriving from the Go banner therefore loses go_plan_launch attribution while both campaigns overlap, undercounting the Go touchpoint and misclassifying the conversion. Preserve an explicit Go campaign and source, or choose the campaign once before building both the event and URL, and add an overlap regression test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

var url = new URL(cta.getAttribute('href'), window.location.origin);
url.searchParams.set('od_origin', 'open_design');
url.searchParams.set('od_entry_id', entryId);
url.searchParams.set('od_entry_source', 'landing_go_banner');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The CTA writes od_entry_source=landing_go_banner, but that value is not in the TrackingAmrEntrySource contract, the web source-to-page map, or the daemon AMR_ENTRY_SOURCES allowlist. parseVelaLoginAttribution fails closed for an unknown source, so this banner's entry is discarded before Cloud attribution and payment. Use an existing allowed source or add this source consistently to the contract, web map, daemon allowlist, and fixture tests.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

.then((r) => (r.ok ? r.json() : null))
.then((data) => {
const user = data && data.user ? data.user : null;
if (isUnpaid(user) || reviewPreview) reveal();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This branch bypasses the audience gate whenever reviewPreview is true, so a signed-in paid response is revealed by ?campaign=go-plan even though the component contract says preview only relaxes the time gate. The preceding non-2xx response path also maps to user=null, and isUnpaid(null) returns true, turning an API failure into an unpaid reveal. Keep signed-out, free, paid, and unknown states distinct; fail closed for non-2xx or unknown responses and let preview bypass only time and dismissal, then add paid, free, error, and preview matrix coverage.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

.toLowerCase();
if (!plan) return false; // signed in, plan unknown → treat as paid
if (plan === 'free' || plan === 'none') return true;
return !PAID_PLANS.some((p) => plan === p || plan.startsWith(p + '_'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The paid-plan classifier only recognizes personal ids go, plus, pro, and max. The shared workspace tier contract also uses team_plus, team_pro, and team_max (including billing-period suffixes), but each of those values falls through to unpaid here. A paid team visitor can therefore receive the Go banner. Reuse the normalized team-aware tier predicate or handle the team namespace and suffixes here, and add the personal/team tier matrix to the banner audience tests.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

折叠一起消失——常驻入口不该这样。刻意放在 dockPortal 之外:项目路由
下 strip 会被传送进聊天列的 dock,这个入口必须留在 header 原位。 */}
{trailingSlot ? (
<div className="workspace-tabs-trailing">{trailingSlot}</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This trailing slot is rendered inside the 44px workspace-tabs-chrome at the far right, but the existing entry-top-right-cluster is a fixed z-index 150 overlay at right:22px and contains the GitHub and account controls. The new Go control remains in the lower z-index 120 chrome beneath that cluster, so signed-in unpaid users can see it underneath the account pill but cannot click it. Mount Go as the cluster's leading slot or reserve a non-overlapping header region, and add a visual or pointer-event regression test with the account cluster present.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

// 和另外两个触点一样,只在活动窗口内展示:窗口一过整体下线,
// 不渲染也不上报(产品决策 2026-08-18,覆盖需求文档 GMK-013 里
// 「角标常驻、仅摘 NEW」的旧口径)。
const active = isUnpaid && Boolean(upgradeUrl) && isGoCampaignActive();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isGoCampaignActive is evaluated only while this component renders; there is no timer or boundary subscription. A shell opened before 20:00 never re-renders at the start and never shows the entry, while one left open through 20:00 stays active after 9/03 and continues to emit/carry campaign attribution. The modal hook and landing banner have the same one-shot clock check. Drive all three surfaces from a shared reactive campaign-window hook or scheduled boundary updates with cleanup, and cover before-start, in-window, and after-end transitions with fake timers.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

(产品口径 2026-08-17,不代替用户切换)
按钮统一为「升级套餐」(2026-08-19 口径);点击埋点在 ChatPane 侧
以 tier_has_fallback 区分两档。 */
const hasFallbackModels =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This exact personal-tier comparison does not recognize the team-namespaced ids that the shared tier contract documents, and ChatPane duplicates the same comparison at lines 1327-1328. A team_plus, team_pro, or team_max workspace will therefore receive the Go no-fallback copy and tier_has_fallback=false even though it has paid fallback models. Normalize the tier once with the existing team-aware helper and reuse that result in both call sites, with personal/team/suffix cases in the guidance and telemetry tests.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

className="chat-error-action"
onClick={() => {
if (isGoLimitCard && retryAssistant) {
trackGoLimitCardClick(analytics.track, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new Go limit-card ui_click payload has no campaign_id, and this recordAmrEntry call passes only metricsConsent instead of campaignId. Unlike the modal and nav entry, a first click from chat therefore lacks the required go_plan_launch dimension and creates a non-campaign attribution with the shorter seven-day TTL. Extend GoLimitCardClickProps and its tracker with campaign_id, pass campaignId: go_plan_launch to recordAmrEntry for chat_go_limit_upgrade, and add an event plus attributed-URL regression test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

context: workspaceContext,
});
const goUpsellUnpaid = goPlanLabelTier === 'free';
const goUpsell = useGoUpsellModal(goUpsellUnpaid, view === 'home');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This hook is gated by unpaid, Home, and the campaign clock but not by goUpsellUpgradeUrl. workspaceUpgradeUrl returns null for a resolved workspace whose member cannot manage billing, and GoUpsellModal hides its only primary CTA when upgradeUrl is null. An unpaid team member can therefore be shown a modal with no actionable path. Gate the hook on a non-null authorized upgrade URL or provide an explicit member-safe destination, and cover the free member case.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

zhanghuihua and others added 2 commits August 19, 2026 11:39
- The 5-hour limit card now shows the full copy in the title slot with
  no 查看详情 disclosure (2026-08-19 ruling) — the copy opens with the
  「高峰期繁忙」 headline, so nothing is lost by dropping the two-level
  structure. Other error cards keep the short-title + details shape.
- run_failed_toast surface_view gains optional failure_detail and
  tier_has_fallback. Without them the limit card's exposures are
  indistinguishable from ordinary RATE_LIMITED 429s — the click-through
  denominator for the ⑤⑥ funnel simply did not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@app/open-design-crew

I reviewed every changed file and range in the current head. The implementation still has blocking issues in the generated Pricing script and fail-closed attribution path, can expose the campaign to paid or incorrectly scoped workspaces, and loses the campaign dimension for the new limit-card funnel. The inline findings below identify the affected lines, production impact, and concrete fixes.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

'od_campaign_id',
'od_conversion_source',
];
const inboundParams = new URLSearchParams(window.location.search);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Remove this second inboundParams declaration. The existing declaration at line 727 remains in the same generated script, so this new const makes the emitted Pricing script fail parsing with Identifier inboundParams has already been declared. That prevents scopedBillingPlanUrl, CTA analytics, and all inbound attribution forwarding from being installed, breaking the pricing interaction path rather than only the Go CTA. Keep one declaration and add a build/contract test that checks the emitted inline script for duplicate bindings.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

'od_entry_id',
'od_entry_source',
'od_entry_at',
'od_campaign_id',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This whitelist now forwards od_campaign_id even though the comment immediately below says it must not. A Go landing click is later passed through __odRecordCampaignEntry with deepseek_v4_pro whenever the overlap window is active, and __odAttributedUrl overwrites go_plan_launch; after the window closes this loop can also preserve a stale inbound Go campaign because the URL helper only sets a campaign when the new attribution has one. Go conversions can therefore be credited to DeepSeek or to an expired campaign. Preserve and validate the inbound Go campaign, choose exactly one campaign for the click, and explicitly remove it when the campaign is closed; cover both overlap and post-window URLs.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

var url = new URL(cta.getAttribute('href'), window.location.origin);
url.searchParams.set('od_origin', 'open_design');
url.searchParams.set('od_entry_id', entryId);
url.searchParams.set('od_entry_source', 'landing_go_banner');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

landing_go_banner is not part of TrackingAmrEntrySource or the daemon AMR_ENTRY_SOURCES/page mapping, and it is not in the daemon conversion-source allowlist either. Pricing copies this inbound value into source_detail and conversion_source, so parseVelaLoginAttribution rejects the whole attribution and the Go banner cannot produce a valid Cloud entry. Reuse the existing landing_home_banner taxonomy, or add a typed landing-Go source plus its contract, page, and conversion allowlist entries on both web and daemon, with an end-to-end URL/parser test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

.then((r) => (r.ok ? r.json() : null))
.then((data) => {
const user = data && data.user ? data.user : null;
if (isUnpaid(user) || reviewPreview) reveal();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This reveal condition still fails open on audience. reviewPreview bypasses isUnpaid, so a signed-in paid user visiting ?campaign=go-plan is shown the Go banner; a non-2xx session response is converted to null at the preceding fetch handler, which is treated as signed-out, and the preview catch path reveals on network errors. That violates the stated paid-zero-events rule and can suppress the DeepSeek banner during an auth outage. Preview should bypass only the time gate, while non-2xx, unknown, and error states remain hidden until an explicit signed-out/free result is known; add paid-preview, 500, and network-error tests.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

.toLowerCase();
if (!plan) return false; // signed in, plan unknown → treat as paid
if (plan === 'free' || plan === 'none') return true;
return !PAID_PLANS.some((p) => plan === p || plan.startsWith(p + '_'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The negated PAID_PLANS check recognizes only personal go/plus/pro/max. A signed-in team_plus/team_pro/team_max user, or any other paid/unknown plan id, falls through the some() call and is classified as unpaid, so the banner is revealed and the existing campaign is suppressed for paying users. Use the shared team-aware free-plan predicate with an explicit known-free allowlist and fail closed for unknown tiers; exercise personal, team-namespaced, and unknown plans in the audience matrix.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

折叠一起消失——常驻入口不该这样。刻意放在 dockPortal 之外:项目路由
下 strip 会被传送进聊天列的 dock,这个入口必须留在 header 原位。 */}
{trailingSlot ? (
<div className="workspace-tabs-trailing">{trailingSlot}</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This trailing slot is rendered at the far right of the header's z-index 120 layer, while the existing .entry-top-right-cluster is fixed at right:22px with z-index 150 in the same pixels. The account cluster will paint above and intercept the Go button, making the new entry unreliable or invisible. Mount the Go entry inside the existing cluster, reserve non-overlapping header space, or otherwise reconcile the two owners, and add a viewport/electron no-drag interaction test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/App.tsx
identityScopeKey={workspaceTabsIdentityScopeKey}
trailingSlot={(
<GoNavEntry
upgradeUrl={workspaceUpgradeUrl(workspaceContext, workspaceBilling)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Project routes already derive activeProjectWorkspaceContext and pass it to the account cluster as the authoritative project scope, but this GoNavEntry uses ambient workspaceContext/workspaceBilling. A deep-linked project in paid workspace A can therefore render the offer and URL for an ambient free workspace B (and switching the ambient workspace can change the entry while A stays open). Derive the nav inputs from the active project scope or mount this offer only on Home, and add an A/B deep-link regression test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

// out. Same string the card renders under 「查看详情」.
failedRunErrorEvent?.detail,
// 档位只有 model_window_limit 分支读:两档限额提示的文案不同。
resolvePlanLabelTier({ context: workspaceContext }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both new tier decisions pass only workspaceContext into resolvePlanLabelTier. WorkspaceCollabContext is null for personal workspaces, so personal Plus/Pro/Max reaches the resolver as unknown; team tiers are namespaced as team_plus/team_pro/team_max while amr-guidance.ts exact-matches only personal names. Paid users are consequently shown the Go copy and tier_has_fallback=false, corrupting both guidance and the funnel split. Pass the workspace-projected billing/account tier and normalize it through the shared team-aware helper, with personal and team paid-tier tests.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

planTier === 'plus' || planTier === 'pro' || planTier === 'max';
return {
primaryAction: 'retry',
primaryAction: 'upgrade',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changing model_window_limit to primaryAction: upgrade applies before the agent-specific branches. The function's own guidance contract says non-AMR model/quota errors use plain retry plus the promotion card, but ChatPane handles any upgrade action by opening amrPlansUrlForProfile. A non-AMR run carrying the same classified detail is therefore sent to AMR plans instead of retrying its provider. Gate this Go upgrade/copy to the AMR agent (or retain the non-AMR retry path) and add a non-AMR action regression test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

...(failedRunErrorEvent.failureDetail
? { failure_detail: failedRunErrorEvent.failureDetail }
: {}),
...(isGoLimitCard ? { tier_has_fallback: goLimitHasFallback } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new model-window surface_view adds tier_has_fallback but no campaign_id. The corresponding GoLimitCardClickProps/click at line 2942 also omits campaign_id, and recordAmrEntry at line 2953 is not passed campaignId, so neither the impression nor the Cloud handoff carries go_plan_launch or its 14-day campaign attribution. Add the campaign field to both contracts and payloads, pass campaignId: go_plan_launch to recordAmrEntry, and assert the event and URL dimensions in tests.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

The home DS banner is now the PAID audience's touchpoint (unpaid users
get the Go banner, which suppresses this one while visible), so its copy
moves off the free-trial framing (2026-08-19 ruling):

- 11-locale copy: 「这次,顶级智能无限用。DeepSeek V4 Pro 与 V4 Flash
  等模型无限用」 and equivalents — "unlimited", no trial dates.
- Countdown removed end to end: the badge span, its dead CSS, the
  querySelector/format/update logic, and the two data-* feeds. The
  exposure beacon used to sit BELOW the countdown update behind an
  `!countdown` early-return, so deleting only the DOM would have
  silently killed the surface_view — it is now decoupled and fires on
  visibility alone. Window start/end still control show/hide.
- Arrow CTA, close button, and the existing tracking (surface_view /
  open_pricing / close under area=campaign_banner, plus the
  landing_home_banner attributed entry) are unchanged.
- Reverted this branch's earlier od_* forwarding block on the pricing
  page: main already forwards od_* params and deliberately re-decides
  od_campaign_id per click; the duplicate declaration broke astro check.
- home-campaign-banner tests updated to the new copy and the
  countdown-free structure (4/4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🚀 Landing page preview

This PR is deployed to a Cloudflare Pages preview — not staging or production:

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@app/open-design-crew

I completed a fresh changed-range pass at the seeded head. The existing unresolved blocking threads remain the merge gate; this pass adds one non-blocking consistency issue in the new nav-entry analytics/state contract.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

/** localStorage key recording that the NEW dot has been spent on this browser. */
export const GO_NAV_ENTRY_SEEN_KEY = 'od.goBadge.clicked';

function hasClickedGoNavEntry(): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new nav entry's NEW-state implementation is dead: hasClickedGoNavEntry (and markGoNavEntryClicked below) are never called, the rendered button has no NEW dot, and GoNavEntryClickProps does not carry the has_new_badge value that its contract comment promises. As a result, clicking the pill cannot clear the cue or distinguish first-sight from repeat clicks, so the campaign cannot evaluate or retire this affordance. Wire a seen state from hasClickedGoNavEntry, render the dot while it is false, mark it before sending the click, and include the pre-click boolean in GoNavEntryClickProps; if the dot was intentionally removed, delete the stale helpers/comment instead. Add a component/event test for first and subsequent clicks.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

PR #6881's staging shows its .hm-announce release bar stacking under
the campaign banner — two full-width top bars at once. While either
campaign banner (Go for unpaid, DS for paid) is visible, the release
bar now yields via CSS; it returns on its own once the campaign window
closes. The selector targets an element that only exists on #6881's
branch, so it is a no-op until that PR lands — merge order between the
two does not matter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@app/open-design-crew

I completed a full changed-range pass at the seeded head. pnpm guard, the workspace typecheck, and focused landing/AMR tests are green, but the implementation still has blocking audience, attribution, workspace-scope, campaign-lifecycle, and error-routing issues described inline. Please resolve these before merge.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

.then((r) => (r.ok ? r.json() : null))
.then((data) => {
const user = data && data.user ? data.user : null;
if (isUnpaid(user) || reviewPreview) reveal();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This condition is not a fail-closed unpaid gate. reviewPreview is ORed into reveal, so a paid user visiting ?campaign=go-plan is shown; the preceding non-OK response is converted to null, which isUnpaid(null) treats as signed-out; and the negative personal-only allowlist above classifies team_plus/team_pro/team_max and other future paid ids as unpaid. That can emit Go events and suppress the DeepSeek surface for paid users, violating the PR's paid-zero-events rule. Keep HTTP/network/unknown states hidden, reveal only an explicit signed-out/free/none result, make preview bypass only time/dismissal, and use a positive team-aware free allowlist with a paid/free/error/preview matrix test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

var url = new URL(cta.getAttribute('href'), window.location.origin);
url.searchParams.set('od_origin', 'open_design');
url.searchParams.set('od_entry_id', entryId);
url.searchParams.set('od_entry_source', 'landing_go_banner');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

landing_go_banner is not an allowed AMR entry source: it is absent from TrackingAmrEntrySource, the web source-to-page map, and the daemon allowlist/page map. The daemon parser intentionally fails closed on an unknown source, so the Go banner's od_entry_source is discarded during the Banner-to-Pricing-to-Cloud handoff and this touchpoint cannot produce a valid attributed entry. Either use an existing landing taxonomy or add this source consistently to the contract, web and daemon maps/allowlists, and parser fixtures.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

context: workspaceContext,
});
const goUpsellUnpaid = goPlanLabelTier === 'free';
const goUpsell = useGoUpsellModal(goUpsellUnpaid, view === 'home');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This hook is gated by the unpaid flag and Home view but not by the computed goUpsellUpgradeUrl. workspaceUpgradeUrl returns null for a resolved workspace whose member cannot manage billing, while GoUpsellModal hides its only primary button when that URL is null. An unpaid team member can therefore receive an impression for an offer with no actionable path. Gate the hook on a non-null authorized URL or provide a member-safe destination, and cover the free-member case.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

const [open, setOpen] = useState(false);

useEffect(() => {
if (!isHomeActive || !isUnpaid) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When isHomeActive or isUnpaid changes to false, this effect simply returns without clearing open. EntryShell remains mounted while switching entry views and the dialog is portaled to document.body, so a modal opened on Home can cover Settings or another view after the gate no longer passes. Clear open on inactive transitions without marking the campaign seen, and add Home-to-other-view and unpaid-to-paid transition coverage.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

}

/** localStorage key recording that this account has seen the campaign modal. */
export const GO_UPSELL_SEEN_KEY = 'od.goUpsell.seen';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment and PR requirement say this is once per account, but od.goUpsell.seen is browser-global. Dismissing it for account A writes the same key that suppresses the modal for account B in the same browser, so B may never receive its account-scoped impression. Scope the key by a stable account/workspace identity plus campaign, or persist the seen state server-side, and test an identity switch.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

// 和另外两个触点一样,只在活动窗口内展示:窗口一过整体下线,
// 不渲染也不上报(产品决策 2026-08-18,覆盖需求文档 GMK-013 里
// 「角标常驻、仅摘 NEW」的旧口径)。
const active = isUnpaid && Boolean(upgradeUrl) && isGoCampaignActive();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isGoCampaignActive() is evaluated only when this component renders; there is no timer or boundary subscription. A shell opened before 20 Aug may never show the entry when the window starts, while one left open after 3 Sep can keep it clickable and emit campaign attribution outside the window. The landing script and modal hook make the same one-shot clock check, so the three windowed surfaces can drift. Drive them from a shared boundary-aware timer/hook with cleanup and cover before-start, in-window, and after-end transitions with fake timers.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

(产品口径 2026-08-17,不代替用户切换)
按钮统一为「升级套餐」(2026-08-19 口径);点击埋点在 ChatPane 侧
以 tier_has_fallback 区分两档。 */
const hasFallbackModels =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This exact comparison recognizes only personal plus/pro/max. The shared tier contract also emits team_plus, team_pro, and team_max including suffixes, and ChatPane duplicates the same comparison when it computes tier_has_fallback. Paid team workspaces therefore receive the Go no-fallback copy and tier_has_fallback=false, corrupting both guidance and funnel segmentation. Normalize the tier once with the existing team-aware helper and reuse it in both call sites, with personal/team/suffix cases in the guidance and telemetry tests.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

className="chat-error-action"
onClick={() => {
if (isGoLimitCard && retryAssistant) {
trackGoLimitCardClick(analytics.track, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new Go limit-card click payload has no campaign_id, and the corresponding recordAmrEntry call below passes only metricsConsent instead of campaignId. The new limit-card surface_view path at line 1715 is also missing the campaign dimension, so neither side of this touchpoint carries go_plan_launch and the Cloud handoff uses the ordinary seven-day attribution TTL. Extend the Go limit-card and surface contracts with campaign_id, pass campaign_id: go_plan_launch to both payloads, and pass campaignId: go_plan_launch to recordAmrEntry; add an event plus attributed-URL regression test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

planTier === 'plus' || planTier === 'pro' || planTier === 'max';
return {
primaryAction: 'retry',
primaryAction: 'upgrade',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new primaryAction: upgrade branch runs before the agent-specific branches. ChatPane handles any upgrade action by opening amrPlansUrlForProfile, so a non-AMR run carrying this classified detail is sent to AMR plans instead of retaining the non-AMR retry/promotion behavior documented above the function. Gate this Go upgrade/copy to the AMR agent, or preserve the non-AMR retry path, and add a regression that asserts both the action and destination for a Claude/BYOK run.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

/** localStorage key recording that the NEW dot has been spent on this browser. */
export const GO_NAV_ENTRY_SEEN_KEY = 'od.goBadge.clicked';

function hasClickedGoNavEntry(): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking consistency issue: the NEW-state implementation is dead. hasClickedGoNavEntry and markGoNavEntryClicked are never called, the rendered button has no NEW dot, and GoNavEntryClickProps does not carry the has_new_badge value promised by its contract comment. Either wire a seen state, render/clear the dot, and include the pre-click boolean in the event, or remove the stale helpers and contract comment if the dot was intentionally dropped; add a first/subsequent-click test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Heads-up: PR #7121 is also open against the Go homepage area — both PRs touch apps/landing-page/app/_components/go-banner.astro, apps/landing-page/app/go-banner-i18n.ts, and apps/landing-page/app/pages/index.astro. Sharing this so the two approaches don't drift while review is in flight; we can decide whether to consolidate or land them separately once feedback settles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-validation Runtime change detected; needs human or /explore agent validation. risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/XL PR changes 700-1500 lines type/feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants