Skip to content

feat(ux): compose-confirm form + Manage rules menu + dashboard onboarding (Phase 1.7b)#44

Merged
ComBba merged 2 commits into
mainfrom
feat/compose-ux-best-practices
May 14, 2026
Merged

feat(ux): compose-confirm form + Manage rules menu + dashboard onboarding (Phase 1.7b)#44
ComBba merged 2 commits into
mainfrom
feat/compose-ux-best-practices

Conversation

@ComBba
Copy link
Copy Markdown
Contributor

@ComBba ComBba commented May 14, 2026

Summary

Best-practice UX rework for the 5 deferred audit findings (#2 #3 #5 #7 #10) plus 4 new items (#A empty states, #B delete confirm, #C onboarding, #D token cost). User explicitly opted-in to Tier 1+2+3 + a separate "Manage rules" menu.

Design + tier rationale:

What changes — 9 audit items

Tier 1 — compose flow

# Change
#2 New `composeConfirmForm` after compile shows the rule in plain English (`humanizeRule()`) + per-compile token cost + Save / Edit branch. Defence-in-depth: serialized rule re-validated on save.
#5 Server-side clarification turn counter; refuses round 4+ with actionable toast. Each clarify modal description prefixed "(Round X of 3)".
#7 Original rule field is now editable in the clarify modal. helpText updated.

Tier 2 — Manage rules menu + dashboard

# Change
#3 + #10 New "vibe-mod: Manage rules" menu — one form-group per rule with a `select` of available actions per rule state (draft / shadow active / live active). Submit applies all non-destructive actions atomically.
#A Manage menu emits guided toast when zero rules. Dashboard shows "No rules yet — open ⋯ → Compose" empty-state.
#B Delete actions forwarded to a confirm form with explicit "I understand this is permanent" boolean.
#D Dashboard description: "Tokens used (lifetime): N in / N out (~$X on gpt-5.4-mini)". Compose confirm form shows per-compile cost.

Tier 3 — Onboarding

# Change
#C Dashboard 3-step welcome card on first visit (per-sub Redis flag `${sub}:onboarding:dismissed`). Cancel-label switches to "Don't show intro again" on first visit.

New endpoints

  • `/internal/form/compose-confirm-submit` (form: composeConfirmForm)
  • `/internal/menu/manage-rules`
  • `/internal/form/manage-rules-submit` (form: manageRulesForm)
  • `/internal/form/manage-delete-confirm` (form: manageDeleteConfirmForm)

Test plan

  • 4/4 gates green: typecheck + lint + Prettier + 209 unit/integration tests + 3 @devvit/test + acceptance G1–G4
  • 8 existing compose tests adapted to the new 2-step flow via a `compileAndConfirm()` helper
  • 17 NEW Manage tests (`routes-manage.test.ts`): menu render, every action verb, delete confirm round-trip
  • 4 NEW onboarding/empty-state tests in routes-dashboard.test.ts
  • 5 NEW Tier-1 tests (compose-confirm Edit/Save, turn-limit toast, turn counter bump, editable original)
  • Manual playtest after merge (D-9 publish prep)

Why this PR before module split (Phase 2b)

Module split is a pure refactor; this is a behaviour change. Demo video records this UI. UX changes must land before:

  • Phase 2b (module split — refactor on the new shape)
  • Phase 3 (README screenshots — capture this UI)
  • Phase 4 (data demo video — record this flow)

D-day status

2026-05-27 18:00 PT (Devpost firm). publish recommended start 2026-05-18 (D-9, 4d). Compose flow already live-verified in production via Chrome automation (PR #43 / v0.0.41). Next deploy = this PR + Phase 2b + Phase 3-5.

🤖 Generated with Claude Code

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 규칙 관리 모더레이터 메뉴 항목 추가 (활성화/일시 정지/삭제 기능 포함)
    • 규칙 작성 시 확인 단계 추가
  • 개선 사항

    • 명확화 라운드 추가 (최대 3회 제한)
    • 대시보드에서 총 토큰 비용 및 온보딩 안내 표시
    • 삭제 확인 모달 강화
    • 모더레이터 권한 검증 실행

Review Change Stack

…ng (Phase 1.7b Tier 1+2+3)

Best-practice UX rework for the 5 deferred audit findings (#2 #3 #5 #7 #10) plus
4 new items (#A empty states, #B delete confirm, #C onboarding, #D token cost).
Design + tier rationale: claudedocs/2026-05-14-ux-best-practices-plan.md.

## Tier 1 — compose flow (audit #2 #5 #7)

### #2 Compile preview confirmation form (composeConfirmForm)
Compile no longer persists straight to draft. After validation, the moderator
sees a confirm form with the rule rendered in plain English (humanizeRule()),
the original sentence, and the per-compile token cost (~$0.0006 on
gpt-5.4-mini). Tick "Edit instead of save" to re-open compose with the
original NL pre-filled. Save persists draft + schedules dry-run via the new
persistRuleAndStartDryRun() helper. Defence-in-depth: serialized rule is
re-validated before write.

New endpoint: /internal/form/compose-confirm-submit
New form: composeConfirmForm

### #5 Clarification turn limit (3 rounds)
Server-side counter shipped as a disabled paragraph field. After round 3, an
oscillating LLM gets refused with an actionable toast ("Try rephrasing more
concretely"), instead of opening yet another modal. Each clarify modal
description prefixed with "(Round X of 3)" so the moderator sees how many
tries remain.

### #7 Editable original rule in clarify modal
Removed `disabled: true` on the Original rule field. helpText now says
"Re-compile uses this text plus your answer below."

## Tier 2 — Manage rules menu + dashboard hardening (audit #3 #10 #A #B #D)

### #3 + #10 Per-rule control surface (Manage rules)
New "vibe-mod: Manage rules" menu (subreddit-level, mod-only). Renders one
form-group per rule with a `select` of available actions:
- Drafts: Keep / Activate (shadow 24h) / Activate immediately / Delete
- Active shadow rules: Keep / Promote shadow → live / Pause / Delete
- Active live rules: Keep / Pause / Delete
Submit applies all non-destructive actions atomically (one redis.set per
bundle) via the new applyManageActions() helper. Dashboard becomes
read-only — its old "Activate N drafts" boolean removed.

New endpoints: /internal/menu/manage-rules,
               /internal/form/manage-rules-submit,
               /internal/form/manage-delete-confirm
New forms: manageRulesForm, manageDeleteConfirmForm

### #B Delete confirmation
Any `delete` action in the Manage submit forwards to a confirm form listing
the rules about to disappear, with an explicit "I understand this is
permanent" boolean. The pending action map round-trips through a disabled
paragraph carrier so the second-step submit knows what to apply.

### #A Empty states
- Manage menu emits a guided toast when there are zero rules at all.
- Dashboard description shows "No rules yet — open ⋯ → Compose" when both
  rules count and recent actions are zero.

### #D Token cost transparency
- Dashboard description: "Tokens used (lifetime): N in / N out (~$X on
  gpt-5.4-mini)" via the new estimateTokenCost() helper.
- Compose confirm form shows per-compile cost so the moderator sees the
  unit economics before saving.

## Tier 3 — Onboarding (audit #C)

Dashboard shows a 3-step welcome card on first visit (per-sub Redis flag
`${sub}:onboarding:dismissed`). Cancel-label switches to "Don't show intro
again" and ticks dismissOnboarding to persist the flag. New key in
src/shared/redis-keys.ts.

## Verification
- `npm run check` 4/4 gates green
- 209 unit + integration tests (1 skipped):
  - 33 compose tests (8 updated for new 2-step flow + 4 new for #2/#5/#7)
  - 13 dashboard tests (4 updated for read-only + 4 new for onboarding/empty)
  - 17 manage tests (NEW file routes-manage.test.ts: menu render, all 5 actions, delete confirm round-trip)
  - existing trigger / scheduler / settings / undo / evaluator / executor unchanged

## Why a single PR
Tier 1+2+3 all touch overlapping files (index.ts, devvit.json, the dashboard form
shape). Splitting would force back-and-forth conflict resolution. Logical
commits inside this PR (split when applicable in follow-up).

Refs: claudedocs/2026-05-14-ux-best-practices-plan.md (Phase 1.7a),
      claudedocs/2026-05-14-compose-flow-audit.md (audit baseline),
      docs/demo-scenario.md (downstream dependency)
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 14, 2026

Warning

Rate limit exceeded

@ComBba has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 49 minutes and 7 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 15e7af56-4fe6-42e4-bf8b-14ad1a7c15f2

📥 Commits

Reviewing files that changed from the base of the PR and between 7469c01 and 804ff08.

📒 Files selected for processing (3)
  • src/server/index.ts
  • src/server/routes-manage.test.ts
  • test/devvit-testkit.ts

Walkthrough

This PR implements Phase 1.7b of vibe-mod, introducing a two-step rule-composition flow with confirmation, clarification turn limiting, dashboard onboarding with token-cost visibility, and a new Manage Rules moderator workflow. The server enforces moderator authorization, persists rule state atomically, and adds defensive configuration reading. All existing tests remain compatible via re-export invariants.

Changes

Phase 1.7b Feature Implementation

Layer / File(s) Summary
Planning documents and UX specification
claudedocs/2026-05-14-module-split-plan.md, claudedocs/2026-05-14-ux-best-practices-plan.md
Phase 1.7b UX plan specifies Tier 1 (confirmation form, turn limits, editable rules, onboarding) and Tier 2 (dashboard redesign, manage rules, delete safety, token transparency). Module-split plan documents a future refactor of src/server/index.ts with pre-split mapping and execution sequence.
Manifest configuration for new forms and menu
devvit.json
Registers a new "Manage rules" moderator menu item and four form submission endpoints: composeConfirmForm, dashboardForm, manageRulesForm, and manageDeleteConfirmForm.
Clarification turn limiting and modal state
src/server/index.ts
Parses and bounds clarification-turn counter (1–99), enforces max 3 turns, adds hidden state-carrier field and round-progress description, makes original-rule field editable, and populates suggested-answer dropdown.
Confirmation form and two-step compose persistence
src/server/index.ts, src/server/routes-compose.test.ts
Adds confirmation step after compilation showing token-cost estimate and model, supports "edit instead" or "save" branching, revalidates before persistence. New compileAndConfirm test helper and expanded coverage for confirm branching, turn limits, and persistence via extracted persistRuleAndStartDryRun() helper.
Dashboard onboarding, token totals, and read-only submission
src/server/index.ts, src/server/routes-dashboard.test.ts, src/shared/redis-keys.ts
Computes lifetime token totals across bundles, adds first-visit onboarding welcome card (Redis-controlled per subreddit), simplifies submit endpoint to read-only for onboarding dismissal. Includes new keys.onboardingDismissed() Redis key helper and tests for onboarding visibility and empty states.
Manage Rules menu and atomic rule-state workflow
src/server/index.ts, src/server/routes-manage.test.ts
Adds moderator-only "Manage rules" menu with per-rule action controls and inline dry-run summaries. Submit collects per-rule selections (keep/activate/pause/promote/delete), short-circuits to delete confirmation when needed, applies state changes atomically via applyManageActions(). New routes-manage.test.ts covers menu, submit, and delete endpoints with functional tests.
Internal helpers and utility functions
src/server/index.ts
Introduces estimateTokenCost() for USD pricing, readOpenaiModel() for defensive settings reading, and applyManageActions() for atomic rule-bundle updates. Adds global MAX_CLARIFY_TURNS constant.

Sequence Diagram

sequenceDiagram
  participant User
  participant ComposeFlow
  participant ConfirmForm
  participant Persistence
  
  User->>ComposeFlow: Submit rule text
  ComposeFlow->>ComposeFlow: Compile rule (with turn limit check)
  alt Needs Clarification
    ComposeFlow->>User: Show clarification modal (Round X of 3)
    User->>ComposeFlow: Answer clarification
    ComposeFlow->>ComposeFlow: Re-compile with answer
  end
  ComposeFlow->>ConfirmForm: Return confirmation form
  ConfirmForm->>User: Show deterministic summary + token cost
  User->>ConfirmForm: "Edit" or "Save"
  alt Edit Path
    ConfirmForm->>ComposeFlow: Reopen compose with original rule
  else Save Path
    ConfirmForm->>Persistence: Revalidate and persist draft
    Persistence->>Persistence: Schedule dry-run
    Persistence->>User: Success toast
  end
Loading

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

  • Two-Weeks-Team/vibe-mod#1: Seeds starter rules on app install; this PR further adjusts draft-only seeding logic in the same src/server/index.ts code path.
  • Two-Weeks-Team/vibe-mod#30: Implements Devvit plugin-RPC resilience patterns; this PR uses similar fail-open moderator-guard and settings-reading patterns in isCallerModerator() and readOpenaiModel().

Poem

🐰 Two steps guard the rule's embrace,
Turn by turn, we find our place,
Onboarding smiles on first-time view,
Token costs laid bare and true,
Manage wisely, delete with care—
Vibe-mod's UX beyond compare!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(ux): compose-confirm form + Manage rules menu + dashboard onboarding (Phase 1.7b)' accurately summarizes the three main changes: new compose confirmation flow, Manage rules menu feature, and dashboard onboarding.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/compose-ux-best-practices

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a series of UX best practices and structural improvements for the vibe-mod server. Key changes include splitting the rule composition into a two-step confirmation flow, introducing a turn limit for LLM clarifications to prevent infinite loops, and creating a dedicated 'Manage rules' interface for per-rule actions (activate, pause, promote, delete). The dashboard has been updated to be read-only with a new onboarding card and token cost transparency. Feedback highlights several critical issues: the rule management logic lacks atomicity and is vulnerable to race conditions during concurrent edits, the 50-rule limit is not enforced when moving rules between bundles, and using 'manage' as a model fallback breaks token cost estimations. Additionally, performance could be improved by parallelizing dry-run result fetches.

Comment thread src/server/index.ts Outdated
Comment on lines +956 to +973
for (const r of drafts) {
try {
const rawDry = await redis.get(keys.dryrun(subredditName, r.id));
if (!rawDry) continue;
const d = JSON.parse(rawDry) as DryRunResult;
if (d.status === 'ok') {
dryRunByRuleId.set(
r.id,
`Dry-run: would match ${d.matched.length}/${d.sampledPosts} recent post(s)` +
(d.matched.length ? ` → ${[...new Set(d.matched.flatMap((m) => m.would))].join(', ')}` : ''),
);
} else {
dryRunByRuleId.set(r.id, `Dry-run: ${d.note ?? 'unavailable'}`);
}
} catch (err) {
console.warn(`[vibe-mod] manage: redis.get(dryrun/${r.id}) threw:`, describeErr(err));
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Fetching dry-run results sequentially in a loop for up to 50 rules can significantly slow down the menu rendering, especially in a serverless environment where Redis latency adds up. Parallelizing these requests would improve performance.

References
  1. Avoid unnecessary loops or sequential iterations for async operations when they can be performed in parallel.

Comment thread src/server/index.ts
// keep | activate-shadow | activate-now | promote | pause | delete
// Plain reads + 1 write per bundle. All plugin RPC wrapped because
// reddit/devvit#258 still rears its head occasionally.
async function applyManageActions(actions: Record<string, string>): Promise<{ persisted: boolean; summary: string }> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The 50-rule limit is not enforced in this function. Actions like 'pause' (moving rules from active to draft) or 'activate' (moving from draft to active) could cause a bundle to exceed the cap if many rules are moved at once. This could lead to performance degradation or memory issues during rule evaluation.

Comment thread src/server/index.ts Outdated
Comment on lines +2010 to +2019
llmModel: draft?.llmModel ?? 'manage',
llmTokensIn: draft?.llmTokensIn ?? 0,
llmTokensOut: draft?.llmTokensOut ?? 0,
rules: draftRules,
};
const activeBundle: RuleBundleType = {
schemaVersion: '1.0.0',
bundleVersion: (active?.bundleVersion ?? 0) + 1,
compiledAt: now,
llmModel: active?.llmModel ?? 'manage',
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using 'manage' as a fallback for llmModel will cause token cost estimations to fail (returning 0) because 'manage' is not a key in the OPENAI_PRICING_USD_PER_TOKEN map. It is better to use the currently configured model name as a default.

Comment thread src/server/index.ts Outdated
try {
writes.push(redis.set(keys.rulesDraft(subredditName), JSON.stringify(draftBundle)));
writes.push(redis.set(keys.rulesActive(subredditName), JSON.stringify(activeBundle)));
await Promise.all(writes);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This update is not atomic, which contradicts the PR description's claim. Promise.all triggers two independent redis.set operations. If one succeeds and the other fails, rules could be duplicated or lost between the draft and active bundles. Furthermore, the read-modify-write pattern used here is vulnerable to race conditions if multiple moderators are managing rules at the same time.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/index.ts (1)

1802-1802: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

정규식이 의도한 동작을 수행하지 않음

정규식 /[-�]/g는 실제로는 hyphen (-)과 문자 (U+FFFF) 두 개만 매칭합니다. 코드 주석의 의도인 "non-ASCII char (>= 0x80)"를 이스케이프하지 않으므로 OpenAI 요청 본문에서 대부분의 비ASCII 문자가 그대로 전달되어 UTF-8 인코딩 문제가 발생합니다.

다음 정규식 중 하나로 수정하세요:

-  const asciiSafeBody = rawBody.replace(/[-�]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
+  const asciiSafeBody = rawBody.replace(/[\u0080-\uffff]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));

또는:

-  const asciiSafeBody = rawBody.replace(/[-�]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
+  const asciiSafeBody = rawBody.replace(/[^\x00-\x7F]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/index.ts` at line 1802, The regex used to produce asciiSafeBody is
wrong (/[�-�]/g matches only '-' and U+FFFF); update the pattern used on rawBody
in the asciiSafeBody assignment to correctly match non-ASCII characters, e.g.
replace /[�-�]/g with /[\u0080-\uFFFF]/g or /[^\x00-\x7F]/g so the replacer (c
=> '\\u' + c.charCodeAt(0).toString(16).padStart(4,'0')) is applied to all
characters >= 0x80.
🧹 Nitpick comments (3)
src/server/index.ts (3)

2146-2194: 💤 Low value

humanizeRule 출력 길이

함수가 전체 PredicateTree를 재귀적으로 렌더링합니다. 매우 복잡한 규칙(깊게 중첩된 all/any)의 경우 출력이 수천 자가 될 수 있어 확인 폼 description을 읽기 어렵게 만들 수 있습니다. 그러나 checkTreeDepth가 이미 깊이를 제한하고 있으므로 실제로는 합리적인 범위 내에 있어야 합니다.

복잡한 규칙에 대해 출력을 약 500자로 제한하고 "... (조건이 더 있음)"을 추가하는 것을 고려해보세요. 그러나 현재 구현은 대부분의 실제 사용 사례에서 작동해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/index.ts` around lines 2146 - 2194, humanizeRule currently renders
the entire PredicateTree via predicateLabel which can produce extremely long
strings for deeply nested/complex rules; cap the output (e.g. ~500 chars) and
append a deterministic truncation marker like "... (more conditions)" so the
compose-confirm description stays readable. Modify humanizeRule/predicateLabel
to track a maxLength constant and either (a) stop recursing once the cumulative
output length would exceed that limit and return a truncated fragment with the
marker, or (b) build the full string then slice to maxLength and append the
marker—ensure the marker is always added deterministically (same input => same
output) and that triggers/actions/ratelimit formatting (variables triggers,
actions, rateLimit) keep their existing structure. Use the function names
predicateLabel and humanizeRule as the insertion points and preserve
indentation/line breaks when truncating.

1933-2044: ⚖️ Poor tradeoff

applyManageActions 원자성 고려사항

함수가 두 개의 개별 redis.set() 호출을 사용하여 draft와 active 번들을 업데이트합니다(lines 2025-2026). 하나는 성공하고 다른 하나는 실패하면 불일치 상태가 발생할 수 있습니다. Devvit Redis는 트랜잭션을 지원하지 않는 것으로 보이지만, 실패 시 두 쓰기 모두 재시도하거나 보상 롤백을 고려해야 합니다.

현재 구현은 두 쓰기 중 하나라도 실패하면 persisted: false를 반환하고 적절한 요약 메시지를 제공합니다. 사용자는 재시도할 수 있습니다. 이는 reddit/devvit#258 불안정성을 고려할 때 합리적인 트레이드오프입니다.

운영 관찰: 부분 쓰기로 인한 불일치가 발생하면 moderator는 Manage rules 메뉴를 다시 열어 현재 상태를 확인하고 의도한 작업을 재적용할 수 있습니다. 미래 개선사항으로는 재시도 로직이나 보상 작업을 추가할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/index.ts` around lines 1933 - 2044, The applyManageActions
function can leave draft and active bundles inconsistent because it performs two
separate redis.set calls (keys.rulesDraft and keys.rulesActive) without
atomicity; modify applyManageActions to ensure both writes succeed or are rolled
back by first reading and storing the previous values (already read into
draft/active), then attempt the two writes with a small retry loop (e.g., 3
attempts with backoff) around redis.set for each key, and if after retries one
write succeeds and the other ultimately fails, perform a compensating redis.set
to restore the prior value for the successful key and set persisted = false so
callers know the operation did not fully persist; keep the summary construction
and error logging (processLogger/console.warn) but ensure you reference and
update keys.rulesDraft and keys.rulesActive and the persisted flag accordingly.

1906-1910: ⚡ Quick win

estimateTokenCost 구현 검토

함수가 알 수 없는 모델에 대해 0을 반환하여 크래시를 방지하는 것은 좋습니다. 그러나 음수 토큰 값에 대한 방어 로직은 없습니다. 입력이 서버 제어 disabled 필드에서 오기 때문에 실제 위험은 낮지만, 방어적 검증을 추가하면 더 견고해집니다.

♻️ 음수 토큰 검증 추가
 function estimateTokenCost(model: string, tokensIn: number, tokensOut: number): number {
   const p = OPENAI_PRICING_USD_PER_TOKEN[model];
   if (!p) return 0;
+  // Defensive: ensure non-negative tokens (should always be true from server-controlled fields)
+  const safeIn = Math.max(0, tokensIn);
+  const safeOut = Math.max(0, tokensOut);
-  return tokensIn * p.in + tokensOut * p.out;
+  return safeIn * p.in + safeOut * p.out;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/index.ts` around lines 1906 - 1910, The estimateTokenCost function
should defensively validate token counts: check tokensIn and tokensOut for
negative values before using them (alongside the existing model lookup from
OPENAI_PRICING_USD_PER_TOKEN), and if any are negative either clamp them to 0 or
return 0 immediately; update the logic in estimateTokenCost to use the
validated/clamped values when computing tokensIn * p.in + tokensOut * p.out to
prevent negative-cost results.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/index.ts`:
- Line 2174: The current return statement uses JSON.stringify(t.value) which can
produce extremely long strings for large objects/arrays and break
compiledSummary; replace this with a truncated formatter that serializes t.value
but limits output length (e.g., 200-500 chars) and appends an ellipsis when
truncated. Implement or call a helper (e.g., formatValue or truncateJson) and
use it in the template return `${indent}${t.fact} ${t.op} ${formatted}` so that
t.value is safe to render in compiledSummary without changing t.fact/t.op
semantics.

---

Outside diff comments:
In `@src/server/index.ts`:
- Line 1802: The regex used to produce asciiSafeBody is wrong (/[�-�]/g matches
only '-' and U+FFFF); update the pattern used on rawBody in the asciiSafeBody
assignment to correctly match non-ASCII characters, e.g. replace /[�-�]/g with
/[\u0080-\uFFFF]/g or /[^\x00-\x7F]/g so the replacer (c => '\\u' +
c.charCodeAt(0).toString(16).padStart(4,'0')) is applied to all characters >=
0x80.

---

Nitpick comments:
In `@src/server/index.ts`:
- Around line 2146-2194: humanizeRule currently renders the entire PredicateTree
via predicateLabel which can produce extremely long strings for deeply
nested/complex rules; cap the output (e.g. ~500 chars) and append a
deterministic truncation marker like "... (more conditions)" so the
compose-confirm description stays readable. Modify humanizeRule/predicateLabel
to track a maxLength constant and either (a) stop recursing once the cumulative
output length would exceed that limit and return a truncated fragment with the
marker, or (b) build the full string then slice to maxLength and append the
marker—ensure the marker is always added deterministically (same input => same
output) and that triggers/actions/ratelimit formatting (variables triggers,
actions, rateLimit) keep their existing structure. Use the function names
predicateLabel and humanizeRule as the insertion points and preserve
indentation/line breaks when truncating.
- Around line 1933-2044: The applyManageActions function can leave draft and
active bundles inconsistent because it performs two separate redis.set calls
(keys.rulesDraft and keys.rulesActive) without atomicity; modify
applyManageActions to ensure both writes succeed or are rolled back by first
reading and storing the previous values (already read into draft/active), then
attempt the two writes with a small retry loop (e.g., 3 attempts with backoff)
around redis.set for each key, and if after retries one write succeeds and the
other ultimately fails, perform a compensating redis.set to restore the prior
value for the successful key and set persisted = false so callers know the
operation did not fully persist; keep the summary construction and error logging
(processLogger/console.warn) but ensure you reference and update keys.rulesDraft
and keys.rulesActive and the persisted flag accordingly.
- Around line 1906-1910: The estimateTokenCost function should defensively
validate token counts: check tokensIn and tokensOut for negative values before
using them (alongside the existing model lookup from
OPENAI_PRICING_USD_PER_TOKEN), and if any are negative either clamp them to 0 or
return 0 immediately; update the logic in estimateTokenCost to use the
validated/clamped values when computing tokensIn * p.in + tokensOut * p.out to
prevent negative-cost results.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 06533742-6c89-41fe-ab91-cbc6db968d3b

📥 Commits

Reviewing files that changed from the base of the PR and between 4f0192d and 7469c01.

📒 Files selected for processing (8)
  • claudedocs/2026-05-14-module-split-plan.md
  • claudedocs/2026-05-14-ux-best-practices-plan.md
  • devvit.json
  • src/server/index.ts
  • src/server/routes-compose.test.ts
  • src/server/routes-dashboard.test.ts
  • src/server/routes-manage.test.ts
  • src/shared/redis-keys.ts

Comment thread src/server/index.ts Outdated
External code-review pointed out 5 valid issues. All addressed in this commit
on the same branch — PR #44 picks them up automatically.

## Gemini code-assist

### #1 — Manage menu: parallelize per-rule dry-run reads
The previous serial `for` loop multiplied Redis latency by the draft count.
With up to 50 drafts and ~10ms per round-trip the difference is ~500ms vs
~10ms wall time. Switched to `Promise.allSettled([...])` so a single failed
fetch doesn't stop the others.

### #2 — applyManageActions: enforce the 50-rule cap on apply
pause / activate / unpause moves can push a bundle over the 50-rule cap
even when no compose-rule call is made. Added an explicit check before
the dual-write txn — the moderator gets a "Rule cap exceeded (active=N,
draft=N, max=50). Delete a rule first" toast and the bundles stay
untouched. New test in routes-manage.test.ts.

### #3 — Use the configured openaiModel as the fallback (not the literal "manage")
The previous `'manage'` fallback for `bundle.llmModel` broke
`estimateTokenCost()` because 'manage' isn't a key in
`OPENAI_PRICING_USD_PER_TOKEN`. The dashboard shows "(~$0 on manage)" until
a real compile lands. Now we read the configured `openaiModel` setting
through `readOpenaiModel()` (already SELECTION-array-safe per PR #39).

### #4 — Atomic dual-write via WATCH/MULTI/EXEC (the previous PR description's claim was wrong)
The previous `Promise.all([redis.set(activeKey, ...), redis.set(draftKey, ...)])`
was NOT atomic — if the second `set` failed (e.g. plugin RPC blip), pause
could leave a rule absent from active without showing up in draft.
Switched to Devvit's `redis.watch(...).multi().set(...).set(...).exec()`
pattern (same shape executor.ts already uses for audit writes), and check
`exec()`'s null return for the WATCH-aborted case so two moderators
managing rules at the same time get an actionable "Another moderator
changed the rules at the same time — re-open Manage rules" toast.

## CodeRabbit

### #5 — humanizeRule: cap value-stringification at 100 chars
A rule that uses `op: in` against a long allowlist (e.g. 50 banned domains)
would otherwise render thousands of characters into the confirm form's
`compiledSummary` field, blowing past Devvit's modal description budget
and obscuring the actual rule structure. Truncate with `...` after 100
chars in the predicate-tree leaf renderer.

## Test infrastructure
test/devvit-testkit.ts: fakeRedis.watch().exec() now returns `[] | null`
(matching real Devvit Redis MULTI/EXEC semantics) instead of `void`.
Without this, the new WATCH-abort detection in applyManageActions would
fire on every test. Updated `FakeTxn.exec` type accordingly.

## Verification
- `npm run check` 4/4 gates green
- 210 tests pass (1 skipped) — added 1 cap-overflow test in routes-manage.test.ts
@ComBba ComBba merged commit 986d969 into main May 14, 2026
2 checks passed
@ComBba ComBba deleted the feat/compose-ux-best-practices branch May 14, 2026 03:39
ComBba pushed a commit that referenced this pull request May 15, 2026
External code-review pointed out 5 valid issues. All addressed in this commit
on the same branch — PR #44 picks them up automatically.

## Gemini code-assist

### #1 — Manage menu: parallelize per-rule dry-run reads
The previous serial `for` loop multiplied Redis latency by the draft count.
With up to 50 drafts and ~10ms per round-trip the difference is ~500ms vs
~10ms wall time. Switched to `Promise.allSettled([...])` so a single failed
fetch doesn't stop the others.

### #2 — applyManageActions: enforce the 50-rule cap on apply
pause / activate / unpause moves can push a bundle over the 50-rule cap
even when no compose-rule call is made. Added an explicit check before
the dual-write txn — the moderator gets a "Rule cap exceeded (active=N,
draft=N, max=50). Delete a rule first" toast and the bundles stay
untouched. New test in routes-manage.test.ts.

### #3 — Use the configured openaiModel as the fallback (not the literal "manage")
The previous `'manage'` fallback for `bundle.llmModel` broke
`estimateTokenCost()` because 'manage' isn't a key in
`OPENAI_PRICING_USD_PER_TOKEN`. The dashboard shows "(~$0 on manage)" until
a real compile lands. Now we read the configured `openaiModel` setting
through `readOpenaiModel()` (already SELECTION-array-safe per PR #39).

### #4 — Atomic dual-write via WATCH/MULTI/EXEC (the previous PR description's claim was wrong)
The previous `Promise.all([redis.set(activeKey, ...), redis.set(draftKey, ...)])`
was NOT atomic — if the second `set` failed (e.g. plugin RPC blip), pause
could leave a rule absent from active without showing up in draft.
Switched to Devvit's `redis.watch(...).multi().set(...).set(...).exec()`
pattern (same shape executor.ts already uses for audit writes), and check
`exec()`'s null return for the WATCH-aborted case so two moderators
managing rules at the same time get an actionable "Another moderator
changed the rules at the same time — re-open Manage rules" toast.

## CodeRabbit

### #5 — humanizeRule: cap value-stringification at 100 chars
A rule that uses `op: in` against a long allowlist (e.g. 50 banned domains)
would otherwise render thousands of characters into the confirm form's
`compiledSummary` field, blowing past Devvit's modal description budget
and obscuring the actual rule structure. Truncate with `...` after 100
chars in the predicate-tree leaf renderer.

## Test infrastructure
test/devvit-testkit.ts: fakeRedis.watch().exec() now returns `[] | null`
(matching real Devvit Redis MULTI/EXEC semantics) instead of `void`.
Without this, the new WATCH-abort detection in applyManageActions would
fire on every test. Updated `FakeTxn.exec` type accordingly.

## Verification
- `npm run check` 4/4 gates green
- 210 tests pass (1 skipped) — added 1 cap-overflow test in routes-manage.test.ts
ComBba added a commit that referenced this pull request May 15, 2026
…ices

feat(ux): compose-confirm form + Manage rules menu + dashboard onboarding (Phase 1.7b)
ComBba pushed a commit that referenced this pull request May 15, 2026
Pre-publish production verification — covers the new flows that PRs
#43, #44, #45 introduced but never round-tripped through Devvit's
actual runtime:

  1. Compose form rendering
  2. Clarify modal renders the **select** field (was paragraph)
  3. Re-compile → composeConfirmForm opens + compiledSummary visible
  4. Save click → success toast
  5. Dashboard onboarding card + token cost line
  6. New "vibe-mod: Manage rules" menu opens with per-rule action select

Each step writes a screenshot + JSON record into playwright/.auth/.
End-of-run summary: playwright/.auth/verify-phase17b-result.json.

Pre-req: `npx devvit upload` (NOT publish) — installs latest code on
r/SocialSeeding for playtest. Cookie source is the same as
chrome-reddit-v3.py (browser_cookie3 reads the user's local Chrome
reddit.com cookies, no creds embedded in the script).

Usage:
  source .venv-chrome-auth/bin/activate
  python scripts/chrome-reddit-verify-phase17b.py
  # HEADLESS=0 for visible browser, REDDIT_SUB=other for a different sub

Why this is needed: 211 unit/integration tests cover the handler logic,
but a production runtime issue (Devvit-specific form-field rendering,
redis.watch/multi/exec semantics, new menu/form name registration) can
still surface only at devvit upload time. Catching those before
`devvit publish --public` saves the Reddit App Directory review round.
ComBba pushed a commit that referenced this pull request May 15, 2026
… DOM

Iteration on the Phase 1.7b/c verify script after running it against the
v0.0.44 install on r/SocialSeeding. Three real DOM gaps surfaced — none
in server code, all in our test harness.

## Server: 14/14 PASS — production confirmed

Surfaced live in this script's last run:
  - Clarify modal renders the suggestedAnswers `select` (PR #43)
  - clarificationTurn carrier + "(Round X of N)" (PR #44)
  - composeConfirmForm with compiledSummary + serializedRule (PR #44)
  - Save toast: 'Compiled rule "X". → post: modqueue. Dry-run started…'
  - Dashboard onboarding card + token cost line (PR #44 Tier 2/3)
  - Manage rules menu with per-rule action_* select fields (PR #44)

## Script changes

1. **dump_form()** — Devvit's modal markup doesn't expose a stable `<h2>`
   title or `[slot="description"]`, so the previous extraction returned
   empty strings on every form. Added `dialogText` (full text-content of
   the dialog) as the source of truth for description-style assertions.

2. **Form detection** — switched from title-text matching to **field-name
   signals**:
   - clarify form ⇔ presence of `clarificationTurn`
   - confirm form ⇔ `compiledSummary` + `serializedRule`
   - dashboard onboarding ⇔ "Welcome to vibe-mod" inside dialogText
   - manage form ⇔ at least one `action_*` field

3. **clarify-select-pick** — the previous picker called
   `locator('select, faceplate-select').click()` which timed out (Devvit
   renders the select as a Lit element with a different tag). Replaced
   with a single `page.evaluate` that scopes by the field's
   data-field-name wrapper, tries native `<select>` first, then any
   option-like child (faceplate-radio-input, faceplate-listbox-item,
   role=option). Successfully picks "Under 24 hours" in production.

## Verified behaviour
\`playwright/.auth/verify-phase17b-result.json\` shows
\`"overall": true\` and 15/15 step PASS.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant