From a96a0d9afd892538e30dbdffd6222bde948dfd7e Mon Sep 17 00:00:00 2001 From: jry Date: Wed, 15 Jul 2026 08:47:56 +0800 Subject: [PATCH 1/7] docs: add auto-reply design Document the frontend-only auto-reply feature for recoverable interruptions (429/503), including per-conversation enable, settings-managed rules, countdown banner, and loop protection. --- .../specs/2026-07-15-auto-reply-design.md | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-auto-reply-design.md diff --git a/docs/superpowers/specs/2026-07-15-auto-reply-design.md b/docs/superpowers/specs/2026-07-15-auto-reply-design.md new file mode 100644 index 000000000..c230beda6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-auto-reply-design.md @@ -0,0 +1,300 @@ +# Auto Reply Design + +**Date:** 2026-07-15 +**Status:** Approved for implementation planning +**Scope:** Chat composer + conversation shell + settings (frontend only) +**Approach:** Frontend-only rule engine watching error/status signals; per-conversation enable; normal user-send path + +## Problem + +Unattended agent tasks often stop on recoverable interruptions such as: + +- `429 Too Many Requests` +- `503 Service Unavailable` +- Claude API retry / connection error surfaces that leave the session idle until a human types something like `继续` + +If those interruptions are auto-handled with a delayed, explicit user-visible reply, unattended success rate improves without hiding what the client is about to send. + +## Goals + +1. Support configurable auto-reply **rules** (match condition, reply text, delay). +2. Ship built-in rules for **HTTP 429** and **HTTP 503** that reply with **`继续`**. +3. Support per-rule **delay** before send. +4. Allow enabling auto-reply **manually from the composer `+` menu**, scoped **per conversation**. +5. Show a clear **pre-send banner above the input** before any automatic message is sent. +6. Prevent infinite loops via **cooldown + max fires per interruption burst**. +7. Reuse the normal composer send path (no special system message type). +8. Cover matching, lifecycle, loop protection, and UI smoke with tests. +9. Deliver a PR with bilingual (EN + ZH) description. + +## Non-goals + +- Backend/Rust-owned auto-send. +- Matching assistant chat body text. +- Global enable (or global default + per-conversation override). +- Cross-device rule sync via backend settings API. +- Auto-answering permission / free-text question / ask-question dialogs. +- Inventing a separate message role or silent resume protocol. + +## Decisions (from product clarification) + +| Topic | Decision | +| --- | --- | +| Enable scope | Per conversation only | +| Rule management | Settings page | +| Match source | Error / status signals only | +| Cancel during countdown | Banner cancel + user manual send | +| Loop protection | Cooldown + max per error burst | +| Architecture | Frontend-only engine (Approach A) | + +## Architecture + +``` +Settings (rules CRUD) + | + v +auto-reply settings store (rules + defaults, app-level) + | +MessageInput + menu --> per-conversation enabled flag + | +useAutoReplyEngine(error / claudeApiRetry / status) + | + |- match first enabled rule + |- schedule countdown (rule.delayMs) + |- banner above composer + +- on fire -> onSend(rule.replyText) // normal user-send path +``` + +### Key integration points + +| Area | File(s) | Role | +| --- | --- | --- | +| Retry / error state | `src/contexts/acp-connections-context.tsx` | Source of `ClaudeApiRetryState` (`errorStatus`, `error`) and connection `error` / `status` | +| Composer dock | `src/components/chat/conversation-shell.tsx` | Host countdown banner above input | +| `+` menu toggle | `src/components/chat/message-input.tsx` | Per-conversation enable | +| Send path | existing `onSend` / queue plumbing | Auto reply sends as a normal user draft | +| Settings shell | `src/components/settings/settings-shell.tsx` + new settings page | Rule CRUD | +| i18n | `src/i18n/messages/*.json` | EN/ZH (and locale key parity as required by repo) | + +## Data model + +```ts +type AutoReplyMatchKind = + | "http_status" // exact HTTP status from ClaudeApiRetryState.errorStatus + | "error_text" // substring match against retry.error / connection error + +interface AutoReplyRule { + id: string + name: string + enabled: boolean + matchKind: AutoReplyMatchKind + matchValue: string // "429" | "503" | "Too Many Requests" | ... + replyText: string // default "继续" + delayMs: number + cooldownMs: number + maxPerBurst: number + builtin?: boolean // shipped rules: editable, not deletable +} + +interface AutoReplySettings { + version: 1 + rules: AutoReplyRule[] +} + +// Per conversation: +// conversationId -> enabled: boolean +``` + +### Built-in seed rules + +| Name | Match | Reply | delayMs | cooldownMs | maxPerBurst | +| --- | --- | --- | --- | --- | --- | +| HTTP 429 | `http_status=429` | `继续` | 3000 | 15000 | 3 | +| HTTP 503 | `http_status=503` | `继续` | 3000 | 15000 | 3 | + +Users may edit delay/reply/cooldown/max and disable builtins, but cannot delete them. + +### Persistence + +- **Rules:** app-level `localStorage` under a versioned key, e.g. `codeg:auto-reply:settings:v1` +- **Enable flag:** per conversation id (and draft/storage key for unsaved drafts if a stable key already exists for that composer instance) +- No backend schema change in v1 + +## Trigger flow + +### When evaluation runs + +Re-evaluate when any of these change: + +- per-conversation Auto Reply enabled +- `claudeApiRetry` +- connection `error` +- connection `status` +- rules list from settings +- pending permission / question / ask-question state (safety gates) + +### Match algorithm + +1. Skip if Auto Reply is **off** for this conversation. +2. Skip if a countdown is already scheduled for this conversation. +3. Skip if connection is not safe to send: + - status is `prompting`, `connecting`, `disconnected`, or `error` in a non-recoverable way that cannot accept a prompt + - pending permission, free-text question, or ask-question dialog is open +4. Build signal snapshot: + - `httpStatus = claudeApiRetry?.errorStatus` + - `errorText = claudeApiRetry?.error ?? connection.error ?? ""` +5. Walk **enabled rules** in list order; first match wins. + - `http_status`: `Number(matchValue) === httpStatus` + - `error_text`: case-sensitive substring of `errorText` containing `matchValue` (document exact policy in code; keep simple) +6. Apply loop protection for that rule + conversation: + - if now < lastSuccessfulSendAt + `cooldownMs` -> skip + - if same interruption burst already reached `maxPerBurst` -> skip and surface a dismissible stop notice +7. Start countdown for `rule.delayMs`. + +### Interruption burst identity + +```ts +burstKey = `${httpStatus ?? "none"}|${normalize(errorText)}` +``` + +`normalize` trims and collapses internal whitespace. Same outage retries share a burst. When the signal clears and a later distinct signal appears, a new burst may fire again. + +### Countdown lifecycle + +``` +matched + -> pending { ruleId, replyText, fireAt, burstKey, matchedLabel } + -> banner visible with live remaining seconds + -> timer fires + -> if still enabled + still safe + signal still relevant + -> onSend(plain text draft of replyText) + -> record lastSentAt + increment burst count + -> else cancel without sending +``` + +### Cancel conditions + +Cancel pending auto-send (do not send) when: + +- user clicks **Cancel** on the banner +- user **manually sends** a message +- Auto Reply is toggled off +- engine unmounts / conversation context is replaced +- connection becomes unsafe (`prompting`, disconnected, pending dialogs) +- matched signal disappears before fire (avoid sending after recovery) + +### Send semantics + +- Auto reply is a **normal user prompt** through the existing `onSend` (or queue-when-busy) path. +- Do **not** invent a system/hidden message type. +- Do **not** clobber composer draft contents; send `replyText` directly, independent of the editor buffer. +- If the session is busy and the product already queues user sends, auto-reply should follow the same busy-send policy as a manual send of that text. Prefer not to invent a second queue policy. + +## UI / UX + +### Composer `+` menu + +Add a menu item in the existing add-actions dropdown: + +- Label: `自动回复` / `Auto Reply` +- Checked / on-off for **this conversation only** +- Click toggles enable; does not open settings +- Optional secondary entry: "Manage rules..." -> Settings -> Auto Reply (nice-to-have) + +When enabled, show a low-noise indicator on the `+` control (tint or small badge) so unattended mode is visible without opening the menu. + +### Pre-send countdown banner (required) + +Render above the input, same width as the composer dock: + +**zh-CN** + +``` +即将自动回复「继续」· 3s [取消] +匹配:HTTP 429 +``` + +**en** + +``` +Auto-replying "continue" in 3s [Cancel] +Matched: HTTP 429 +``` + +Notes: + +- Live countdown +- Quote the exact reply text +- Info/warning styling, distinct from the existing Claude API retry destructive strip +- If both exist, auto-reply banner stays closest to the input (action context) + +### Settings page + +New settings nav item (near Quick Messages): + +- Rule list (builtins first) +- Per rule: enable, name, match kind, match value, reply text, delay (seconds UI), cooldown, max per burst +- Builtin badge; builtins not deletable +- Custom rules: add / delete / reorder (first match wins) +- Help text: matches error/status signals only; per-conversation enable lives in composer `+` menu + +### States + +| State | UI | +| --- | --- | +| Off for conversation | No banner; menu unchecked | +| On, no match | No banner | +| On, countdown | Banner + cancel | +| Hit maxPerBurst | Dismissible notice: auto-reply stopped for this burst | + +## Safety defaults + +| Field | Builtin default | +| --- | --- | +| `delayMs` | 3000 | +| `cooldownMs` | 15000 | +| `maxPerBurst` | 3 | + +## Testing + +1. **Rule matching** + - 429 / 503 http status + - error_text substring + - disabled rules ignored + - first match wins +2. **Engine lifecycle** + - disabled conversation never schedules + - schedule uses `delayMs` + - cancel / manual send prevents send + - fire only when still safe +3. **Loop protection** + - cooldown blocks re-fire + - maxPerBurst stops burst + - new burst can fire again +4. **UI smoke** + - `+` toggle flips per-conversation state + - banner copy + cancel + - settings edit of builtin delay/reply + +## PR delivery + +- Branch from `origin/main` (do not stack on unrelated feature branches). +- Implementation plan under `docs/superpowers/plans/`. +- PR description includes **Chinese + English** sections: + - Summary / 摘要 + - Motivation / 动机 + - Behavior / 行为 + - Test plan / 测试计划 + +## Success criteria + +- With Auto Reply on, an unattended conversation recovering from 429/503 sends `继续` after the configured delay. +- User always sees an explicit pre-send warning and can cancel. +- Persistent rate limits do not produce an infinite auto-send loop. +- No backend protocol changes required for v1. + +## Open implementation notes + +- Prefer pure helpers (`matchAutoReplyRule`, `shouldScheduleAutoReply`, burst key) for unit tests without mounting the full composer. +- Prefer a small dedicated store/hook module under `src/lib/auto-reply/` or `src/hooks/use-auto-reply.ts` + `src/stores/` only if an existing local pattern fits better. +- Follow existing i18n key parity rules used by the repo for settings/composer strings. From 83a45791213fd9acc7caabe4d73c33dbbcf999f1 Mon Sep 17 00:00:00 2001 From: jry Date: Wed, 15 Jul 2026 09:02:22 +0800 Subject: [PATCH 2/7] docs: add auto-reply implementation plan Break the frontend-only auto-reply feature into TDD tasks for match helpers, storage, engine lifecycle, i18n, banner/+ menu, settings, and PR. --- .../plans/2026-07-15-auto-reply.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-auto-reply.md diff --git a/docs/superpowers/plans/2026-07-15-auto-reply.md b/docs/superpowers/plans/2026-07-15-auto-reply.md new file mode 100644 index 000000000..77da92baa --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-auto-reply.md @@ -0,0 +1,174 @@ +# Auto Reply Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a frontend-only Auto Reply engine that recovers unattended agent tasks from 429/503-style interruptions by auto-sending a configured reply (default `继续`) after a visible countdown, with per-conversation enable, settings-managed rules, and loop protection. + +**Architecture:** Pure match/storage helpers under `src/lib/auto-reply/` feed a conversation-scoped `useAutoReplyEngine` hook. The engine watches error/status signals, schedules a countdown banner above the composer, and fires through the normal `onSend(PromptDraft)` path. Rules live in app-level localStorage; enable flags are per conversation/draft key. + +**Tech Stack:** React, TypeScript, next-intl, Vitest, localStorage, existing shadcn UI (`DropdownMenuCheckboxItem`, `Switch`, `Button`, `Input`, `Select`). + +## Global Constraints + +- Frontend-only; no backend/Rust protocol changes. +- Match **error/status signals only** (not assistant body text). +- Enable scope is **per conversation only** (composer `+` menu). +- Rule CRUD lives on a **Settings** page. +- Built-ins: HTTP 429 and HTTP 503 -> reply `继续`; `delayMs=3000`, `cooldownMs=15000`, `maxPerBurst=3`. +- Built-ins editable, **not deletable**. +- Pre-send banner above input with live countdown + cancel. +- Manual user send cancels pending auto-reply. +- Safe send only when connected-safe and no permission/question dialogs; never while `prompting`. +- Do not clobber composer draft; send `replyText` as a standalone draft. +- Prettier: no semicolons, trailing commas `es5`, 2-space indent. +- i18n key parity across all 10 locales (`en` is source of truth). +- `docs/superpowers/**` is gitignored — always `git add -f` for plan/spec commits. + +--- + +## File map + +| Path | Responsibility | +| --- | --- | +| `src/lib/auto-reply/types.ts` | Shared types + builtin seed factory | +| `src/lib/auto-reply/match.ts` | Pure matching, safety, burst helpers | +| `src/lib/auto-reply/match.test.ts` | Unit tests for match helpers | +| `src/lib/auto-reply/storage.ts` | localStorage load/save for settings + enable map | +| `src/lib/auto-reply/storage.test.ts` | Storage tests | +| `src/lib/auto-reply/settings-store.ts` | In-memory settings cache + subscribers | +| `src/hooks/use-auto-reply-engine.ts` | Countdown lifecycle hook | +| `src/hooks/use-auto-reply-engine.test.ts` | Fake-timer lifecycle tests | +| `src/components/chat/auto-reply-banner.tsx` | Countdown + stop-notice UI | +| `src/components/settings/auto-reply-settings.tsx` | Rules CRUD UI | +| `src/app/settings/auto-reply/page.tsx` | Settings route | +| `src/components/settings/settings-shell.tsx` | Nav entry | +| `src/components/chat/conversation-shell.tsx` | Host engine + banner | +| `src/components/chat/message-input.tsx` | `+` menu toggle | +| `src/components/chat/chat-input.tsx` | Pass enable props | +| `src/i18n/messages/*.json` | Strings (10 locales) | + +Storage keys: +- `codeg:auto-reply:settings:v1` +- `codeg:auto-reply:enabled:v1` + +Enable key: prefer `draftStorageKey` when present, else a stable conversation/virtual id. + +--- + +### Task 1: Pure types + match helpers + +**Files:** +- Create: `src/lib/auto-reply/types.ts` +- Create: `src/lib/auto-reply/match.ts` +- Test: `src/lib/auto-reply/match.test.ts` + +**Interfaces:** +- Produces: `AutoReplyRule`, `AutoReplySettings`, `AutoReplyMatchKind`, `AutoReplySignal`, `AutoReplySafetyInput`, `createBuiltinRules()`, `normalizeErrorText()`, `buildBurstKey()`, `findMatchingRule()`, `canScheduleAutoReply()`, `isSafeToAutoReply()`, `signalFromSources()`, `buildAutoReplyDraft()` + +- [ ] **Step 1: Write failing tests** in `match.test.ts` covering builtins, http_status, error_text first-match, burst key, cooldown/maxPerBurst, safety gates, signal source preference. +- [ ] **Step 2: Run** `pnpm exec vitest run src/lib/auto-reply/match.test.ts` (expect FAIL). +- [ ] **Step 3: Implement** types + match helpers as specified in design. +- [ ] **Step 4: Re-run tests** (expect PASS). +- [ ] **Step 5: Commit** `feat(auto-reply): add match helpers and builtin 429/503 rules` + +Builtin rule ids: `builtin-http-429`, `builtin-http-503`. +`isSafeToAutoReply`: status must be `"connected"` and no pending permission/question/ask-question. +`error_text` match is case-sensitive substring. +Burst key: `` `${httpStatus ?? "none"}|${normalize(errorText)}` ``. + +--- + +### Task 2: Storage + settings store + +**Files:** +- Create: `src/lib/auto-reply/storage.ts` +- Create: `src/lib/auto-reply/settings-store.ts` +- Test: `src/lib/auto-reply/storage.test.ts` + +- [ ] **Step 1: Tests** for defaults, corrupt JSON, round-trip, enable map, re-inject missing builtins. +- [ ] **Step 2: Implement** localStorage helpers + `useSyncExternalStore` settings store. +- [ ] **Step 3: Pass tests + commit** `feat(auto-reply): persist rules and per-conversation enable flags` + +--- + +### Task 3: Engine hook + +**Files:** +- Create: `src/hooks/use-auto-reply-engine.ts` +- Test: `src/hooks/use-auto-reply-engine.test.ts` + +API: + +```ts +useAutoReplyEngine({ + enabled, status, error, claudeApiRetry, + pendingPermission, pendingQuestion, pendingAskQuestion, + onSend, +}): { + pending, stopNotice, cancelPending, notifyManualSend, dismissStopNotice +} +``` + +- [ ] **Step 1: Fake-timer tests** for disable, schedule/fire, cancel, manual send, unsafe cancel, signal clear, cooldown, maxPerBurst, new burst. +- [ ] **Step 2: Implement hook** with refs for timers/burst counters; re-check safety at fire. +- [ ] **Step 3: Commit** `feat(auto-reply): add countdown engine with loop protection` + +--- + +### Task 4: i18n (all 10 locales) + +- SettingsShell.nav.auto_reply +- AutoReplySettings.* (settings page strings) +- Folder.chat.messageInput.autoReply (+ hint) +- Folder.chat.autoReply.* (banner/stop notice) + +- [ ] Add keys to en + zh-CN with real copy; other locales can mirror EN but must keep parity. +- [ ] Run `pnpm exec vitest run src/i18n/messages.test.ts` +- [ ] Commit `feat(auto-reply): add i18n strings for composer and settings` + +--- + +### Task 5: Banner + shell / menu wiring + +- Create `auto-reply-banner.tsx` +- Wire engine in `conversation-shell.tsx` (banner closest to input) +- Wrap composer send to call `notifyManualSend` +- Pass enable state through chat-input -> message-input +- `+` menu `DropdownMenuCheckboxItem` toggle; tint `+` when enabled + +- [ ] Implement + commit `feat(auto-reply): wire countdown banner and + menu toggle` + +--- + +### Task 6: Settings page + nav + +- `src/components/settings/auto-reply-settings.tsx` +- `src/app/settings/auto-reply/page.tsx` +- Nav item near Quick Messages; builtins not deletable; delay/cooldown in seconds UI + +- [ ] Implement + commit `feat(auto-reply): add settings page for rule management` + +--- + +### Task 7: Verify + bilingual PR + +```bash +pnpm exec vitest run src/lib/auto-reply src/hooks/use-auto-reply-engine.test.ts src/i18n/messages.test.ts +git add -f docs/superpowers/plans/2026-07-15-auto-reply.md +git push -u origin feat/auto-reply +gh pr create --base main --head feat/auto-reply --title "feat: auto-reply for recoverable agent interruptions (429/503)" --body "..." +``` + +PR body must include Chinese + English sections: Summary, Motivation, Behavior, Test plan. + +--- + +## Self-review + +1. Spec coverage: enable toggle, settings rules, 429/503 builtins, delay, banner, cancel, manual-send cancel, cooldown/maxPerBurst, normal onSend, bilingual PR — covered. +2. No intentional placeholders. +3. Shared types/names consistent across tasks. + +## Execution note + +User has repeatedly said 继续 — execute inline on `feat/auto-reply` after committing this plan. Do not restore the repeat-intent stash onto this branch. From 5b6e57ccb9182ea11174c7805dfd6a27ceb9df4a Mon Sep 17 00:00:00 2001 From: jry Date: Wed, 15 Jul 2026 09:08:27 +0800 Subject: [PATCH 3/7] feat(auto-reply): add match helpers, storage, and countdown engine Introduce pure rule matching for 429/503, localStorage settings/enable maps, and a conversation-scoped engine with cancel, cooldown, and max-per-burst protection. --- src/hooks/use-auto-reply-engine.test.ts | 231 ++++++++++++++++ src/hooks/use-auto-reply-engine.ts | 354 ++++++++++++++++++++++++ src/lib/auto-reply/match.test.ts | 160 +++++++++++ src/lib/auto-reply/match.ts | 122 ++++++++ src/lib/auto-reply/settings-store.ts | 64 +++++ src/lib/auto-reply/storage.test.ts | 105 +++++++ src/lib/auto-reply/storage.ts | 182 ++++++++++++ src/lib/auto-reply/types.ts | 42 +++ 8 files changed, 1260 insertions(+) create mode 100644 src/hooks/use-auto-reply-engine.test.ts create mode 100644 src/hooks/use-auto-reply-engine.ts create mode 100644 src/lib/auto-reply/match.test.ts create mode 100644 src/lib/auto-reply/match.ts create mode 100644 src/lib/auto-reply/settings-store.ts create mode 100644 src/lib/auto-reply/storage.test.ts create mode 100644 src/lib/auto-reply/storage.ts create mode 100644 src/lib/auto-reply/types.ts diff --git a/src/hooks/use-auto-reply-engine.test.ts b/src/hooks/use-auto-reply-engine.test.ts new file mode 100644 index 000000000..9f77313b1 --- /dev/null +++ b/src/hooks/use-auto-reply-engine.test.ts @@ -0,0 +1,231 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type { PromptDraft } from "@/lib/types" +import { + AUTO_REPLY_SETTINGS_KEY, + createDefaultAutoReplySettings, +} from "@/lib/auto-reply/storage" +import { __resetAutoReplySettingsStoreForTests } from "@/lib/auto-reply/settings-store" +import { useAutoReplyEngine } from "./use-auto-reply-engine" + +function draftTexts(calls: PromptDraft[][]): string[] { + return calls.map((args) => args[0]?.displayText ?? "") +} + +function baseArgs(overrides: Partial[0]> = {}) { + return { + enabled: true, + status: "connected" as const, + error: null, + claudeApiRetry: { + sessionId: "s1", + attempt: 1, + maxRetries: 3, + error: "Too Many Requests", + errorStatus: 429, + retryDelayMs: 1000, + }, + pendingPermission: false, + pendingQuestion: false, + pendingAskQuestion: false, + onSend: vi.fn(), + ...overrides, + } +} + +beforeEach(() => { + vi.useFakeTimers() + window.localStorage.clear() + window.localStorage.setItem( + AUTO_REPLY_SETTINGS_KEY, + JSON.stringify(createDefaultAutoReplySettings()) + ) + __resetAutoReplySettingsStoreForTests() +}) + +afterEach(() => { + vi.useRealTimers() + window.localStorage.clear() + __resetAutoReplySettingsStoreForTests() +}) + +describe("useAutoReplyEngine", () => { + it("does not schedule when disabled", () => { + const onSend = vi.fn() + const { result } = renderHook(() => + useAutoReplyEngine(baseArgs({ enabled: false, onSend })) + ) + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(result.current.pending).toBeNull() + expect(onSend).not.toHaveBeenCalled() + }) + + it("schedules 429 and sends continue after delayMs", () => { + const onSend = vi.fn() + const { result } = renderHook(() => useAutoReplyEngine(baseArgs({ onSend }))) + expect(result.current.pending?.replyText).toBe("\u7ee7\u7eed") + expect(result.current.pending?.ruleId).toBe("builtin-http-429") + + act(() => { + vi.advanceTimersByTime(2999) + }) + expect(onSend).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(onSend).toHaveBeenCalledTimes(1) + expect(draftTexts(onSend.mock.calls)).toEqual(["\u7ee7\u7eed"]) + expect(result.current.pending).toBeNull() + }) + + it("cancelPending prevents send", () => { + const onSend = vi.fn() + const { result } = renderHook(() => useAutoReplyEngine(baseArgs({ onSend }))) + act(() => { + result.current.cancelPending() + }) + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(onSend).not.toHaveBeenCalled() + }) + + it("notifyManualSend prevents send", () => { + const onSend = vi.fn() + const { result } = renderHook(() => useAutoReplyEngine(baseArgs({ onSend }))) + act(() => { + result.current.notifyManualSend() + }) + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(onSend).not.toHaveBeenCalled() + }) + + it("cancels when connection becomes unsafe", () => { + const onSend = vi.fn() + const { result, rerender } = renderHook( + (props) => useAutoReplyEngine(props), + { initialProps: baseArgs({ onSend }) } + ) + expect(result.current.pending).not.toBeNull() + rerender(baseArgs({ onSend, status: "prompting" })) + expect(result.current.pending).toBeNull() + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(onSend).not.toHaveBeenCalled() + }) + + it("cancels when signal clears before fire", () => { + const onSend = vi.fn() + const { result, rerender } = renderHook( + (props) => useAutoReplyEngine(props), + { initialProps: baseArgs({ onSend }) } + ) + expect(result.current.pending).not.toBeNull() + rerender( + baseArgs({ + onSend, + claudeApiRetry: null, + error: null, + }) + ) + expect(result.current.pending).toBeNull() + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(onSend).not.toHaveBeenCalled() + }) + + it("enforces maxPerBurst and surfaces stop notice", () => { + const onSend = vi.fn() + // maxPerBurst default is 3; fire three times with enough cooldown gap. + // Use a short-cooldown custom settings set. + const settings = createDefaultAutoReplySettings() + settings.rules = settings.rules.map((rule) => + rule.id === "builtin-http-429" + ? { ...rule, delayMs: 100, cooldownMs: 0, maxPerBurst: 2 } + : rule + ) + window.localStorage.setItem(AUTO_REPLY_SETTINGS_KEY, JSON.stringify(settings)) + __resetAutoReplySettingsStoreForTests() + + const { result, rerender } = renderHook( + (props) => useAutoReplyEngine(props), + { initialProps: baseArgs({ onSend }) } + ) + + act(() => { + vi.advanceTimersByTime(100) + }) + expect(onSend).toHaveBeenCalledTimes(1) + + // Re-introduce the same signal after send cleared pending. + rerender(baseArgs({ onSend, claudeApiRetry: null })) + rerender(baseArgs({ onSend })) + act(() => { + vi.advanceTimersByTime(100) + }) + expect(onSend).toHaveBeenCalledTimes(2) + + rerender(baseArgs({ onSend, claudeApiRetry: null })) + rerender(baseArgs({ onSend })) + act(() => { + vi.advanceTimersByTime(100) + }) + expect(onSend).toHaveBeenCalledTimes(2) + expect(result.current.stopNotice?.reason).toBe("max_per_burst") + }) + + it("allows a new burst after the signal changes", () => { + const onSend = vi.fn() + const settings = createDefaultAutoReplySettings() + settings.rules = settings.rules.map((rule) => ({ + ...rule, + delayMs: 50, + cooldownMs: 0, + maxPerBurst: 1, + })) + window.localStorage.setItem(AUTO_REPLY_SETTINGS_KEY, JSON.stringify(settings)) + __resetAutoReplySettingsStoreForTests() + + const { rerender } = renderHook((props) => useAutoReplyEngine(props), { + initialProps: baseArgs({ onSend }), + }) + act(() => { + vi.advanceTimersByTime(50) + }) + expect(onSend).toHaveBeenCalledTimes(1) + + // Same 429 signal should be blocked by maxPerBurst=1. + rerender(baseArgs({ onSend, claudeApiRetry: null })) + rerender(baseArgs({ onSend })) + act(() => { + vi.advanceTimersByTime(50) + }) + expect(onSend).toHaveBeenCalledTimes(1) + + // Distinct 503 signal is a new burst. + rerender( + baseArgs({ + onSend, + claudeApiRetry: { + sessionId: "s1", + attempt: 1, + maxRetries: 3, + error: "Service Unavailable", + errorStatus: 503, + retryDelayMs: 1000, + }, + }) + ) + act(() => { + vi.advanceTimersByTime(50) + }) + expect(onSend).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/hooks/use-auto-reply-engine.ts b/src/hooks/use-auto-reply-engine.ts new file mode 100644 index 000000000..78fe4b5df --- /dev/null +++ b/src/hooks/use-auto-reply-engine.ts @@ -0,0 +1,354 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { ClaudeApiRetryState } from "@/contexts/acp-connections-context" +import type { ConnectionStatus, PromptDraft } from "@/lib/types" +import { + buildAutoReplyDraft, + buildBurstKey, + canScheduleAutoReply, + findMatchingRule, + isSafeToAutoReply, + signalFromSources, +} from "@/lib/auto-reply/match" +import { useAutoReplySettings } from "@/lib/auto-reply/settings-store" +import type { AutoReplyRule } from "@/lib/auto-reply/types" + +export interface AutoReplyPendingState { + ruleId: string + replyText: string + fireAt: number + remainingMs: number + matchedLabel: string + burstKey: string +} + +export interface AutoReplyStopNotice { + reason: "max_per_burst" + matchedLabel: string +} + +export interface UseAutoReplyEngineArgs { + enabled: boolean + status: ConnectionStatus | null + error: string | null + claudeApiRetry: ClaudeApiRetryState | null + pendingPermission: boolean + pendingQuestion: boolean + pendingAskQuestion: boolean + onSend: (draft: PromptDraft) => void +} + +export interface UseAutoReplyEngineResult { + pending: AutoReplyPendingState | null + stopNotice: AutoReplyStopNotice | null + cancelPending: () => void + notifyManualSend: () => void + dismissStopNotice: () => void +} + +function matchedLabelFor(rule: AutoReplyRule): string { + if (rule.matchKind === "http_status") { + return `HTTP ${rule.matchValue}` + } + return rule.name || rule.matchValue +} + +function hasSignalText(text: string): boolean { + return text.trim().length > 0 +} + +export function useAutoReplyEngine( + args: UseAutoReplyEngineArgs +): UseAutoReplyEngineResult { + const settings = useAutoReplySettings() + const [pending, setPending] = useState(null) + const [stopNotice, setStopNotice] = useState( + null + ) + const [nowTick, setNowTick] = useState(() => Date.now()) + + const pendingRef = useRef(null) + const fireTimerRef = useRef | null>(null) + const tickTimerRef = useRef | null>(null) + const lastSentAtByRuleRef = useRef>(new Map()) + const burstCountByKeyRef = useRef>(new Map()) + const stopNoticeBurstKeysRef = useRef>(new Set()) + // After cancel / manual send, do not auto-reschedule the same burst until + // the interruption signal changes (or clears). + const suppressedBurstKeyRef = useRef(null) + const onSendRef = useRef(args.onSend) + onSendRef.current = args.onSend + + const clearTimers = useCallback(() => { + if (fireTimerRef.current !== null) { + clearTimeout(fireTimerRef.current) + fireTimerRef.current = null + } + if (tickTimerRef.current !== null) { + clearInterval(tickTimerRef.current) + tickTimerRef.current = null + } + }, []) + + const clearPending = useCallback(() => { + clearTimers() + pendingRef.current = null + setPending(null) + }, [clearTimers]) + + const cancelPending = useCallback(() => { + if (pendingRef.current) { + suppressedBurstKeyRef.current = pendingRef.current.burstKey + } + clearPending() + }, [clearPending]) + + const notifyManualSend = useCallback(() => { + if (pendingRef.current) { + suppressedBurstKeyRef.current = pendingRef.current.burstKey + } + clearPending() + }, [clearPending]) + + const dismissStopNotice = useCallback(() => { + setStopNotice(null) + }, []) + + const signal = useMemo( + () => + signalFromSources({ + claudeApiRetry: args.claudeApiRetry + ? { + errorStatus: args.claudeApiRetry.errorStatus, + error: args.claudeApiRetry.error, + } + : null, + connectionError: args.error, + }), + [args.claudeApiRetry, args.error] + ) + + const safety = useMemo( + () => ({ + status: args.status, + pendingPermission: args.pendingPermission, + pendingQuestion: args.pendingQuestion, + pendingAskQuestion: args.pendingAskQuestion, + }), + [ + args.status, + args.pendingPermission, + args.pendingQuestion, + args.pendingAskQuestion, + ] + ) + + const firePending = useCallback(() => { + const current = pendingRef.current + if (!current) return + + const stillSafe = isSafeToAutoReply({ + status: args.status, + pendingPermission: args.pendingPermission, + pendingQuestion: args.pendingQuestion, + pendingAskQuestion: args.pendingAskQuestion, + }) + if (!args.enabled || !stillSafe) { + clearPending() + return + } + + const liveSignal = signalFromSources({ + claudeApiRetry: args.claudeApiRetry + ? { + errorStatus: args.claudeApiRetry.errorStatus, + error: args.claudeApiRetry.error, + } + : null, + connectionError: args.error, + }) + const liveRule = findMatchingRule(settings.rules, liveSignal) + if (!liveRule || liveRule.id !== current.ruleId) { + clearPending() + return + } + if (buildBurstKey(liveSignal) !== current.burstKey) { + clearPending() + return + } + + const now = Date.now() + const lastSentAt = lastSentAtByRuleRef.current.get(liveRule.id) ?? 0 + const burstCount = burstCountByKeyRef.current.get(current.burstKey) ?? 0 + const gate = canScheduleAutoReply({ + rule: liveRule, + now, + lastSentAt, + burstCount, + }) + if (!gate.ok) { + clearPending() + if (gate.reason === "max_per_burst") { + setStopNotice({ + reason: "max_per_burst", + matchedLabel: matchedLabelFor(liveRule), + }) + } + return + } + + clearPending() + // Suppress re-schedule of this same burst until the signal changes. + suppressedBurstKeyRef.current = current.burstKey + lastSentAtByRuleRef.current.set(liveRule.id, now) + burstCountByKeyRef.current.set(current.burstKey, burstCount + 1) + onSendRef.current(buildAutoReplyDraft(liveRule.replyText)) + }, [ + args.claudeApiRetry, + args.enabled, + args.error, + args.pendingAskQuestion, + args.pendingPermission, + args.pendingQuestion, + args.status, + clearPending, + settings.rules, + ]) + + const schedule = useCallback( + (rule: AutoReplyRule, burstKey: string) => { + clearTimers() + const fireAt = Date.now() + Math.max(0, rule.delayMs) + const next: AutoReplyPendingState = { + ruleId: rule.id, + replyText: rule.replyText, + fireAt, + remainingMs: Math.max(0, fireAt - Date.now()), + matchedLabel: matchedLabelFor(rule), + burstKey, + } + pendingRef.current = next + setPending(next) + setNowTick(Date.now()) + + fireTimerRef.current = setTimeout(() => { + firePending() + }, Math.max(0, rule.delayMs)) + + tickTimerRef.current = setInterval(() => { + setNowTick(Date.now()) + }, 250) + }, + [clearTimers, firePending] + ) + + useEffect(() => { + if (!pendingRef.current) return + const current = pendingRef.current + const remainingMs = Math.max(0, current.fireAt - nowTick) + setPending((prev) => { + if ( + !prev || + prev.ruleId !== current.ruleId || + prev.fireAt !== current.fireAt + ) { + return prev + } + if (prev.remainingMs === remainingMs) return prev + const next = { ...prev, remainingMs } + pendingRef.current = next + return next + }) + }, [nowTick]) + + useEffect(() => { + if (!args.enabled) { + clearPending() + return + } + + if (!isSafeToAutoReply(safety)) { + if (pendingRef.current) clearPending() + return + } + + const activeSignal = + signal.httpStatus !== null || hasSignalText(signal.errorText) + if (!activeSignal) { + suppressedBurstKeyRef.current = null + if (pendingRef.current) clearPending() + return + } + + const rule = findMatchingRule(settings.rules, signal) + if (!rule) { + if (pendingRef.current) clearPending() + return + } + + const burstKey = buildBurstKey(signal) + + // If the signal changed away from a previously suppressed burst, allow again. + if ( + suppressedBurstKeyRef.current && + suppressedBurstKeyRef.current !== burstKey + ) { + suppressedBurstKeyRef.current = null + } + + if (pendingRef.current) { + if ( + pendingRef.current.ruleId === rule.id && + pendingRef.current.burstKey === burstKey + ) { + return + } + clearPending() + } + + if (suppressedBurstKeyRef.current === burstKey) { + return + } + + const now = Date.now() + const lastSentAt = lastSentAtByRuleRef.current.get(rule.id) ?? 0 + const burstCount = burstCountByKeyRef.current.get(burstKey) ?? 0 + const gate = canScheduleAutoReply({ + rule, + now, + lastSentAt, + burstCount, + }) + if (!gate.ok) { + if ( + gate.reason === "max_per_burst" && + !stopNoticeBurstKeysRef.current.has(burstKey) + ) { + stopNoticeBurstKeysRef.current.add(burstKey) + setStopNotice({ + reason: "max_per_burst", + matchedLabel: matchedLabelFor(rule), + }) + } + return + } + + schedule(rule, burstKey) + }, [args.enabled, clearPending, safety, schedule, settings.rules, signal]) + + useEffect(() => { + return () => { + clearTimers() + pendingRef.current = null + } + }, [clearTimers]) + + return { + pending, + stopNotice, + cancelPending, + notifyManualSend, + dismissStopNotice, + } +} \ No newline at end of file diff --git a/src/lib/auto-reply/match.test.ts b/src/lib/auto-reply/match.test.ts new file mode 100644 index 000000000..278a8c520 --- /dev/null +++ b/src/lib/auto-reply/match.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest" +import { + buildAutoReplyDraft, + buildBurstKey, + canScheduleAutoReply, + createBuiltinRules, + findMatchingRule, + isSafeToAutoReply, + normalizeErrorText, + signalFromSources, +} from "./match" +import type { AutoReplyRule } from "./types" + +const CONTINUE = "\u7ee7\u7eed" + +function rule( + partial: Partial & Pick +): AutoReplyRule { + return { + name: partial.name ?? partial.id, + enabled: partial.enabled ?? true, + matchKind: partial.matchKind ?? "http_status", + matchValue: partial.matchValue ?? "429", + replyText: partial.replyText ?? CONTINUE, + delayMs: partial.delayMs ?? 3000, + cooldownMs: partial.cooldownMs ?? 15000, + maxPerBurst: partial.maxPerBurst ?? 3, + builtin: partial.builtin, + ...partial, + } +} + +describe("auto-reply match helpers", () => { + it("seeds 429 and 503 builtins that reply continue", () => { + const rules = createBuiltinRules() + expect(rules).toHaveLength(2) + expect(rules.map((r) => r.matchValue)).toEqual(["429", "503"]) + expect(rules.every((r) => r.replyText === CONTINUE && r.builtin)).toBe(true) + }) + + it("matches http_status exactly and ignores disabled rules", () => { + const rules = [ + rule({ id: "off", enabled: false, matchValue: "429" }), + rule({ id: "on", matchValue: "429" }), + ] + expect(findMatchingRule(rules, { httpStatus: 429, errorText: "" })?.id).toBe( + "on" + ) + expect(findMatchingRule(rules, { httpStatus: 503, errorText: "" })).toBeNull() + }) + + it("matches error_text as case-sensitive substring; first wins", () => { + const rules = [ + rule({ + id: "first", + matchKind: "error_text", + matchValue: "Too Many", + }), + rule({ + id: "second", + matchKind: "error_text", + matchValue: "Too Many Requests", + }), + ] + expect( + findMatchingRule(rules, { + httpStatus: null, + errorText: "Error: Too Many Requests", + })?.id + ).toBe("first") + expect( + findMatchingRule(rules, { + httpStatus: null, + errorText: "too many requests", + }) + ).toBeNull() + }) + + it("builds stable burst keys and normalizes whitespace", () => { + expect(normalizeErrorText(" a b\n c ")).toBe("a b c") + expect( + buildBurstKey({ httpStatus: 429, errorText: " rate limit " }) + ).toBe("429|rate limit") + expect(buildBurstKey({ httpStatus: null, errorText: "" })).toBe("none|") + }) + + it("applies cooldown and maxPerBurst", () => { + const r = rule({ id: "r", cooldownMs: 1000, maxPerBurst: 2 }) + expect( + canScheduleAutoReply({ + rule: r, + now: 5000, + lastSentAt: 4500, + burstCount: 0, + }) + ).toEqual({ ok: false, reason: "cooldown" }) + expect( + canScheduleAutoReply({ + rule: r, + now: 6000, + lastSentAt: 0, + burstCount: 2, + }) + ).toEqual({ ok: false, reason: "max_per_burst" }) + expect( + canScheduleAutoReply({ + rule: r, + now: 6000, + lastSentAt: 0, + burstCount: 1, + }) + ).toEqual({ ok: true }) + }) + + it("only allows safe connection states", () => { + expect( + isSafeToAutoReply({ + status: "connected", + pendingPermission: false, + pendingQuestion: false, + pendingAskQuestion: false, + }) + ).toBe(true) + expect( + isSafeToAutoReply({ + status: "prompting", + pendingPermission: false, + pendingQuestion: false, + pendingAskQuestion: false, + }) + ).toBe(false) + expect( + isSafeToAutoReply({ + status: "connected", + pendingPermission: true, + pendingQuestion: false, + pendingAskQuestion: false, + }) + ).toBe(false) + }) + + it("prefers claudeApiRetry error over connection error", () => { + expect( + signalFromSources({ + claudeApiRetry: { + errorStatus: 429, + error: "Too Many Requests", + }, + connectionError: "other", + }) + ).toEqual({ httpStatus: 429, errorText: "Too Many Requests" }) + }) + + it("builds a plain-text PromptDraft", () => { + expect(buildAutoReplyDraft(CONTINUE)).toEqual({ + blocks: [{ type: "text", text: CONTINUE }], + displayText: CONTINUE, + }) + }) +}) \ No newline at end of file diff --git a/src/lib/auto-reply/match.ts b/src/lib/auto-reply/match.ts new file mode 100644 index 000000000..4887242ec --- /dev/null +++ b/src/lib/auto-reply/match.ts @@ -0,0 +1,122 @@ +import type { PromptDraft } from "@/lib/types" +import type { + AutoReplyRule, + AutoReplySafetyInput, + AutoReplyScheduleCheckInput, + AutoReplyScheduleResult, + AutoReplySignal, +} from "./types" + +const DEFAULT_REPLY = "继续" + +export function createBuiltinRules(): AutoReplyRule[] { + return [ + { + id: "builtin-http-429", + name: "HTTP 429", + enabled: true, + matchKind: "http_status", + matchValue: "429", + replyText: DEFAULT_REPLY, + delayMs: 3000, + cooldownMs: 15000, + maxPerBurst: 3, + builtin: true, + }, + { + id: "builtin-http-503", + name: "HTTP 503", + enabled: true, + matchKind: "http_status", + matchValue: "503", + replyText: DEFAULT_REPLY, + delayMs: 3000, + cooldownMs: 15000, + maxPerBurst: 3, + builtin: true, + }, + ] +} + +export function normalizeErrorText(text: string): string { + return text.trim().replace(/\s+/g, " ") +} + +export function buildBurstKey(signal: AutoReplySignal): string { + const status = + signal.httpStatus === null || signal.httpStatus === undefined + ? "none" + : String(signal.httpStatus) + return status + "|" + normalizeErrorText(signal.errorText) +} + +function matchesRule(rule: AutoReplyRule, signal: AutoReplySignal): boolean { + if (rule.matchKind === "http_status") { + if (signal.httpStatus === null || signal.httpStatus === undefined) { + return false + } + const expected = Number(rule.matchValue) + if (!Number.isFinite(expected)) return false + return expected === signal.httpStatus + } + + if (rule.matchKind === "error_text") { + if (!rule.matchValue) return false + return signal.errorText.includes(rule.matchValue) + } + + return false +} + +export function findMatchingRule( + rules: AutoReplyRule[], + signal: AutoReplySignal +): AutoReplyRule | null { + for (const rule of rules) { + if (!rule.enabled) continue + if (matchesRule(rule, signal)) return rule + } + return null +} + +export function canScheduleAutoReply( + input: AutoReplyScheduleCheckInput +): AutoReplyScheduleResult { + const { rule, now, lastSentAt, burstCount } = input + if (lastSentAt > 0 && now < lastSentAt + rule.cooldownMs) { + return { ok: false, reason: "cooldown" } + } + if (burstCount >= rule.maxPerBurst) { + return { ok: false, reason: "max_per_burst" } + } + return { ok: true } +} + +export function isSafeToAutoReply(input: AutoReplySafetyInput): boolean { + if (input.status !== "connected") return false + if (input.pendingPermission) return false + if (input.pendingQuestion) return false + if (input.pendingAskQuestion) return false + return true +} + +export function signalFromSources(input: { + claudeApiRetry: { errorStatus: number | null; error: string | null } | null + connectionError: string | null | undefined +}): AutoReplySignal { + const httpStatus = input.claudeApiRetry?.errorStatus ?? null + const errorText = + input.claudeApiRetry?.error ?? input.connectionError ?? "" + return { + httpStatus, + errorText, + } +} + +export function buildAutoReplyDraft(text: string): PromptDraft { + return { + blocks: [{ type: "text", text }], + displayText: text, + } +} + diff --git a/src/lib/auto-reply/settings-store.ts b/src/lib/auto-reply/settings-store.ts new file mode 100644 index 000000000..9ae41d0f6 --- /dev/null +++ b/src/lib/auto-reply/settings-store.ts @@ -0,0 +1,64 @@ +"use client" + +import { useSyncExternalStore } from "react" +import { + loadAutoReplySettings, + saveAutoReplySettings, +} from "./storage" +import type { AutoReplySettings } from "./types" + +let cached: AutoReplySettings | null = null +const listeners = new Set<() => void>() + +function getSnapshot(): AutoReplySettings { + if (!cached) { + cached = loadAutoReplySettings() + } + return cached +} + +function getServerSnapshot(): AutoReplySettings { + // SSR: always default builtins without touching localStorage. + return loadAutoReplySettings() +} + +function emit() { + for (const listener of listeners) listener() +} + +export function subscribeAutoReplySettings(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function getAutoReplySettings(): AutoReplySettings { + return getSnapshot() +} + +export function updateAutoReplySettings( + next: + | AutoReplySettings + | ((prev: AutoReplySettings) => AutoReplySettings) +): AutoReplySettings { + const prev = getSnapshot() + const resolved = typeof next === "function" ? next(prev) : next + saveAutoReplySettings(resolved) + cached = loadAutoReplySettings() + emit() + return cached +} + +export function useAutoReplySettings(): AutoReplySettings { + return useSyncExternalStore( + subscribeAutoReplySettings, + getSnapshot, + getServerSnapshot + ) +} + +/** Test helper: drop module cache so the next read reloads from storage. */ +export function __resetAutoReplySettingsStoreForTests(): void { + cached = null +} diff --git a/src/lib/auto-reply/storage.test.ts b/src/lib/auto-reply/storage.test.ts new file mode 100644 index 000000000..dd93c2d88 --- /dev/null +++ b/src/lib/auto-reply/storage.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from "vitest" +import { + AUTO_REPLY_ENABLED_KEY, + AUTO_REPLY_SETTINGS_KEY, + createDefaultAutoReplySettings, + ensureBuiltinRules, + isAutoReplyEnabled, + loadAutoReplySettings, + loadEnabledMap, + saveAutoReplySettings, + setAutoReplyEnabled, +} from "./storage" +import type { AutoReplyRule } from "./types" + +afterEach(() => { + window.localStorage.clear() +}) + +describe("auto-reply storage", () => { + it("returns builtin defaults when storage is empty", () => { + const settings = loadAutoReplySettings() + expect(settings.version).toBe(1) + expect(settings.rules.map((r) => r.id)).toEqual([ + "builtin-http-429", + "builtin-http-503", + ]) + }) + + it("returns defaults for corrupt JSON", () => { + window.localStorage.setItem(AUTO_REPLY_SETTINGS_KEY, "{not-json") + const settings = loadAutoReplySettings() + expect(settings).toEqual(createDefaultAutoReplySettings()) + }) + + it("round-trips custom rule edits", () => { + const settings = createDefaultAutoReplySettings() + settings.rules[0] = { + ...settings.rules[0], + replyText: "resume", + delayMs: 5000, + } + settings.rules.push({ + id: "custom-1", + name: "Custom", + enabled: true, + matchKind: "error_text", + matchValue: "rate limit", + replyText: "继续", + delayMs: 1000, + cooldownMs: 10000, + maxPerBurst: 2, + }) + saveAutoReplySettings(settings) + const loaded = loadAutoReplySettings() + expect(loaded.rules[0].replyText).toBe("resume") + expect(loaded.rules[0].delayMs).toBe(5000) + expect(loaded.rules.some((r) => r.id === "custom-1")).toBe(true) + }) + + it("re-injects missing builtins while keeping user edits", () => { + const custom: AutoReplyRule = { + id: "custom-1", + name: "Custom", + enabled: true, + matchKind: "error_text", + matchValue: "boom", + replyText: "ok", + delayMs: 1000, + cooldownMs: 1000, + maxPerBurst: 1, + } + const edited429: AutoReplyRule = { + id: "builtin-http-429", + name: "HTTP 429", + enabled: false, + matchKind: "http_status", + matchValue: "429", + replyText: "go", + delayMs: 9000, + cooldownMs: 1000, + maxPerBurst: 5, + builtin: true, + } + const merged = ensureBuiltinRules([edited429, custom]) + expect(merged.map((r) => r.id)).toEqual([ + "builtin-http-429", + "builtin-http-503", + "custom-1", + ]) + expect(merged[0].replyText).toBe("go") + expect(merged[0].enabled).toBe(false) + expect(merged[1].matchValue).toBe("503") + }) + + it("persists per-conversation enable flags", () => { + expect(isAutoReplyEnabled("conv-1")).toBe(false) + setAutoReplyEnabled("conv-1", true) + expect(isAutoReplyEnabled("conv-1")).toBe(true) + expect(loadEnabledMap()["conv-1"]).toBe(true) + setAutoReplyEnabled("conv-1", false) + expect(isAutoReplyEnabled("conv-1")).toBe(false) + const raw = window.localStorage.getItem(AUTO_REPLY_ENABLED_KEY) + expect(raw).toBeTruthy() + }) +}) diff --git a/src/lib/auto-reply/storage.ts b/src/lib/auto-reply/storage.ts new file mode 100644 index 000000000..cc03170a0 --- /dev/null +++ b/src/lib/auto-reply/storage.ts @@ -0,0 +1,182 @@ +import { createBuiltinRules } from "./match" +import type { AutoReplyRule, AutoReplySettings } from "./types" + +export const AUTO_REPLY_SETTINGS_KEY = "codeg:auto-reply:settings:v1" +export const AUTO_REPLY_ENABLED_KEY = "codeg:auto-reply:enabled:v1" + +const VALID_MATCH_KINDS = new Set(["http_status", "error_text"]) + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function sanitizeRule(raw: unknown): AutoReplyRule | null { + if (!isRecord(raw)) return null + if (typeof raw.id !== "string" || !raw.id) return null + if (typeof raw.name !== "string") return null + if (typeof raw.enabled !== "boolean") return null + if (typeof raw.matchKind !== "string" || !VALID_MATCH_KINDS.has(raw.matchKind)) { + return null + } + if (typeof raw.matchValue !== "string") return null + if (typeof raw.replyText !== "string") return null + if (typeof raw.delayMs !== "number" || !Number.isFinite(raw.delayMs)) return null + if (typeof raw.cooldownMs !== "number" || !Number.isFinite(raw.cooldownMs)) { + return null + } + if (typeof raw.maxPerBurst !== "number" || !Number.isFinite(raw.maxPerBurst)) { + return null + } + + const rule: AutoReplyRule = { + id: raw.id, + name: raw.name, + enabled: raw.enabled, + matchKind: raw.matchKind as AutoReplyRule["matchKind"], + matchValue: raw.matchValue, + replyText: raw.replyText, + delayMs: Math.max(0, Math.trunc(raw.delayMs)), + cooldownMs: Math.max(0, Math.trunc(raw.cooldownMs)), + maxPerBurst: Math.max(1, Math.trunc(raw.maxPerBurst)), + } + if (raw.builtin === true) rule.builtin = true + return rule +} + +/** Ensure builtin rules always exist. Keep user edits for known builtin ids. */ +export function ensureBuiltinRules(rules: AutoReplyRule[]): AutoReplyRule[] { + const builtins = createBuiltinRules() + const byId = new Map(rules.map((rule) => [rule.id, rule])) + const merged: AutoReplyRule[] = [] + + for (const builtin of builtins) { + const existing = byId.get(builtin.id) + if (existing) { + merged.push({ + ...existing, + id: builtin.id, + builtin: true, + }) + byId.delete(builtin.id) + } else { + merged.push(builtin) + } + } + + for (const rule of rules) { + if (byId.has(rule.id)) { + const next = { ...rule } + if (next.builtin) delete next.builtin + // Non-seed ids should not pretend to be builtins. + if (next.id.startsWith("builtin-")) { + // Keep flag only for known seeds handled above; drop orphans' builtin bit. + delete next.builtin + } + merged.push(next) + byId.delete(rule.id) + } + } + + return merged +} + +export function createDefaultAutoReplySettings(): AutoReplySettings { + return { + version: 1, + rules: createBuiltinRules(), + } +} + +export function normalizeAutoReplySettings( + raw: unknown +): AutoReplySettings { + if (!isRecord(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) { + return createDefaultAutoReplySettings() + } + + const rules: AutoReplyRule[] = [] + const seen = new Set() + for (const item of raw.rules) { + const rule = sanitizeRule(item) + if (!rule) continue + if (seen.has(rule.id)) continue + seen.add(rule.id) + rules.push(rule) + } + + return { + version: 1, + rules: ensureBuiltinRules(rules), + } +} + +export function loadAutoReplySettings(): AutoReplySettings { + if (typeof window === "undefined") return createDefaultAutoReplySettings() + try { + const raw = window.localStorage.getItem(AUTO_REPLY_SETTINGS_KEY) + if (!raw) return createDefaultAutoReplySettings() + return normalizeAutoReplySettings(JSON.parse(raw) as unknown) + } catch { + return createDefaultAutoReplySettings() + } +} + +export function saveAutoReplySettings(settings: AutoReplySettings): void { + if (typeof window === "undefined") return + const normalized = normalizeAutoReplySettings(settings) + try { + window.localStorage.setItem( + AUTO_REPLY_SETTINGS_KEY, + JSON.stringify(normalized) + ) + } catch { + /* ignore quota/permission failures */ + } +} + +export type AutoReplyEnabledMap = Record + +export function loadEnabledMap(): AutoReplyEnabledMap { + if (typeof window === "undefined") return {} + try { + const raw = window.localStorage.getItem(AUTO_REPLY_ENABLED_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as unknown + if (!isRecord(parsed)) return {} + const out: AutoReplyEnabledMap = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "boolean") out[key] = value + } + return out + } catch { + return {} + } +} + +export function saveEnabledMap(map: AutoReplyEnabledMap): void { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(AUTO_REPLY_ENABLED_KEY, JSON.stringify(map)) + } catch { + /* ignore */ + } +} + +export function isAutoReplyEnabled(enableKey: string): boolean { + if (!enableKey) return false + return loadEnabledMap()[enableKey] === true +} + +export function setAutoReplyEnabled( + enableKey: string, + enabled: boolean +): void { + if (!enableKey) return + const map = loadEnabledMap() + if (enabled) { + map[enableKey] = true + } else { + delete map[enableKey] + } + saveEnabledMap(map) +} diff --git a/src/lib/auto-reply/types.ts b/src/lib/auto-reply/types.ts new file mode 100644 index 000000000..9b6a5be86 --- /dev/null +++ b/src/lib/auto-reply/types.ts @@ -0,0 +1,42 @@ +export type AutoReplyMatchKind = "http_status" | "error_text" + +export interface AutoReplyRule { + id: string + name: string + enabled: boolean + matchKind: AutoReplyMatchKind + matchValue: string + replyText: string + delayMs: number + cooldownMs: number + maxPerBurst: number + builtin?: boolean +} + +export interface AutoReplySettings { + version: 1 + rules: AutoReplyRule[] +} + +export interface AutoReplySignal { + httpStatus: number | null + errorText: string +} + +export interface AutoReplySafetyInput { + status: string | null | undefined + pendingPermission: boolean + pendingQuestion: boolean + pendingAskQuestion: boolean +} + +export interface AutoReplyScheduleCheckInput { + rule: AutoReplyRule + now: number + lastSentAt: number + burstCount: number +} + +export type AutoReplyScheduleResult = + | { ok: true } + | { ok: false; reason: "cooldown" | "max_per_burst" } From edcc629fc5c88a0156448cf07165172fd8e88719 Mon Sep 17 00:00:00 2001 From: jry Date: Wed, 15 Jul 2026 09:14:17 +0800 Subject: [PATCH 4/7] feat(auto-reply): add i18n strings for composer and settings Add Auto Reply copy across all locales for the + menu toggle, countdown banner, and settings page. EN and zh-CN use real copy; other locales keep key parity with EN. --- src/i18n/messages/ar.json | 44 ++++++++++++++++++++++++++++++++++-- src/i18n/messages/de.json | 44 ++++++++++++++++++++++++++++++++++-- src/i18n/messages/en.json | 44 ++++++++++++++++++++++++++++++++++-- src/i18n/messages/es.json | 44 ++++++++++++++++++++++++++++++++++-- src/i18n/messages/fr.json | 44 ++++++++++++++++++++++++++++++++++-- src/i18n/messages/ja.json | 44 ++++++++++++++++++++++++++++++++++-- src/i18n/messages/ko.json | 44 ++++++++++++++++++++++++++++++++++-- src/i18n/messages/pt.json | 44 ++++++++++++++++++++++++++++++++++-- src/i18n/messages/zh-CN.json | 44 ++++++++++++++++++++++++++++++++++-- src/i18n/messages/zh-TW.json | 44 ++++++++++++++++++++++++++++++++++-- 10 files changed, 420 insertions(+), 20 deletions(-) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 18044c1da..fc9bf8cbe 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -53,7 +53,8 @@ "office_tools": "أدوات المكتب", "skill_packs": "حزم المهارات", "quick_messages": "رسائل سريعة", - "logs": "سجلات التشغيل" + "logs": "سجلات التشغيل", + "auto_reply": "Auto Reply" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "الوكلاء", "mentionGroupSession": "الجلسات", "mentionGroupCommit": "عمليات الإيداع", - "mentionGroupSkill": "المهارات" + "mentionGroupSkill": "المهارات", + "autoReply": "Auto Reply", + "autoReplyEnabledHint": "Auto Reply is on for this conversation" }, "messageQueue": { "addToQueue": "إضافة للقائمة", @@ -2632,6 +2635,15 @@ "cardCompleted": "اكتملت مهمة الخلفية", "cardFinishedWithStatus": "انتهت مهمة الخلفية ({status})", "cardResultPending": "لم تصل النتيجة بعد" + }, + "autoReply": { + "bannerTitle": "Auto-replying \"{reply}\" in {seconds}s", + "bannerMatched": "Matched: {label}", + "cancel": "Cancel", + "stopNotice": "Auto-reply stopped for this interruption (max retries reached).", + "dismiss": "Dismiss", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "Error text: {value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "الوصف وموجّه النظام", "fieldDescription": "الوصف", "baseInstructions": "موجّه النظام (base_instructions)" + }, + "AutoReplySettings": { + "title": "Auto Reply", + "description": "Configure rules that automatically send a recovery message when error or status signals match. Enable Auto Reply per conversation from the composer + menu.", + "help": "Rules match Claude API retry / connection error signals only (not assistant text). First enabled match wins. Built-in rules can be edited but not deleted.", + "addRule": "Add rule", + "save": "Save", + "delete": "Delete", + "name": "Name", + "enabled": "Enabled", + "matchKind": "Match kind", + "matchValue": "Match value", + "replyText": "Reply text", + "delaySeconds": "Delay (seconds)", + "cooldownSeconds": "Cooldown (seconds)", + "maxPerBurst": "Max per burst", + "builtinBadge": "Built-in", + "cannotDeleteBuiltin": "Built-in rules cannot be deleted.", + "matchKindHttpStatus": "HTTP status", + "matchKindErrorText": "Error text contains", + "empty": "No rules yet", + "moveUp": "Move up", + "moveDown": "Move down", + "saved": "Rules saved", + "confirmDeleteTitle": "Delete rule?", + "confirmDeleteDescription": "This custom auto-reply rule will be removed.", + "cancel": "Cancel", + "newRuleName": "New rule" } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 953805e00..0f1418003 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -53,7 +53,8 @@ "office_tools": "Office-Tools", "skill_packs": "Skill-Pakete", "quick_messages": "Schnellnachrichten", - "logs": "Laufzeitprotokolle" + "logs": "Laufzeitprotokolle", + "auto_reply": "Auto Reply" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "Agenten", "mentionGroupSession": "Sitzungen", "mentionGroupCommit": "Commits", - "mentionGroupSkill": "Fähigkeiten" + "mentionGroupSkill": "Fähigkeiten", + "autoReply": "Auto Reply", + "autoReplyEnabledHint": "Auto Reply is on for this conversation" }, "messageQueue": { "addToQueue": "Zur Warteschlange", @@ -2632,6 +2635,15 @@ "cardCompleted": "Hintergrundaufgabe abgeschlossen", "cardFinishedWithStatus": "Hintergrundaufgabe beendet ({status})", "cardResultPending": "Ergebnis steht noch aus" + }, + "autoReply": { + "bannerTitle": "Auto-replying \"{reply}\" in {seconds}s", + "bannerMatched": "Matched: {label}", + "cancel": "Cancel", + "stopNotice": "Auto-reply stopped for this interruption (max retries reached).", + "dismiss": "Dismiss", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "Error text: {value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "Beschreibung & System-Prompt", "fieldDescription": "Beschreibung", "baseInstructions": "System-Prompt (base_instructions)" + }, + "AutoReplySettings": { + "title": "Auto Reply", + "description": "Configure rules that automatically send a recovery message when error or status signals match. Enable Auto Reply per conversation from the composer + menu.", + "help": "Rules match Claude API retry / connection error signals only (not assistant text). First enabled match wins. Built-in rules can be edited but not deleted.", + "addRule": "Add rule", + "save": "Save", + "delete": "Delete", + "name": "Name", + "enabled": "Enabled", + "matchKind": "Match kind", + "matchValue": "Match value", + "replyText": "Reply text", + "delaySeconds": "Delay (seconds)", + "cooldownSeconds": "Cooldown (seconds)", + "maxPerBurst": "Max per burst", + "builtinBadge": "Built-in", + "cannotDeleteBuiltin": "Built-in rules cannot be deleted.", + "matchKindHttpStatus": "HTTP status", + "matchKindErrorText": "Error text contains", + "empty": "No rules yet", + "moveUp": "Move up", + "moveDown": "Move down", + "saved": "Rules saved", + "confirmDeleteTitle": "Delete rule?", + "confirmDeleteDescription": "This custom auto-reply rule will be removed.", + "cancel": "Cancel", + "newRuleName": "New rule" } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 16cef83ec..8ffccf1bf 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -53,7 +53,8 @@ "office_tools": "Office Tools", "skill_packs": "Skill Packs", "quick_messages": "Quick Messages", - "logs": "Runtime Logs" + "logs": "Runtime Logs", + "auto_reply": "Auto Reply" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "Agents", "mentionGroupSession": "Sessions", "mentionGroupCommit": "Commits", - "mentionGroupSkill": "Skills" + "mentionGroupSkill": "Skills", + "autoReply": "Auto Reply", + "autoReplyEnabledHint": "Auto Reply is on for this conversation" }, "messageQueue": { "addToQueue": "Queue message", @@ -2632,6 +2635,15 @@ "cardCompleted": "Background task completed", "cardFinishedWithStatus": "Background task finished ({status})", "cardResultPending": "Result not returned yet" + }, + "autoReply": { + "bannerTitle": "Auto-replying \"{reply}\" in {seconds}s", + "bannerMatched": "Matched: {label}", + "cancel": "Cancel", + "stopNotice": "Auto-reply stopped for this interruption (max retries reached).", + "dismiss": "Dismiss", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "Error text: {value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "Description & system prompt", "fieldDescription": "Description", "baseInstructions": "System prompt (base_instructions)" + }, + "AutoReplySettings": { + "title": "Auto Reply", + "description": "Configure rules that automatically send a recovery message when error or status signals match. Enable Auto Reply per conversation from the composer + menu.", + "help": "Rules match Claude API retry / connection error signals only (not assistant text). First enabled match wins. Built-in rules can be edited but not deleted.", + "addRule": "Add rule", + "save": "Save", + "delete": "Delete", + "name": "Name", + "enabled": "Enabled", + "matchKind": "Match kind", + "matchValue": "Match value", + "replyText": "Reply text", + "delaySeconds": "Delay (seconds)", + "cooldownSeconds": "Cooldown (seconds)", + "maxPerBurst": "Max per burst", + "builtinBadge": "Built-in", + "cannotDeleteBuiltin": "Built-in rules cannot be deleted.", + "matchKindHttpStatus": "HTTP status", + "matchKindErrorText": "Error text contains", + "empty": "No rules yet", + "moveUp": "Move up", + "moveDown": "Move down", + "saved": "Rules saved", + "confirmDeleteTitle": "Delete rule?", + "confirmDeleteDescription": "This custom auto-reply rule will be removed.", + "cancel": "Cancel", + "newRuleName": "New rule" } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1e946a224..d7dcff3da 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -53,7 +53,8 @@ "office_tools": "Herramientas de oficina", "skill_packs": "Paquetes de habilidades", "quick_messages": "Mensajes rápidos", - "logs": "Registros de ejecución" + "logs": "Registros de ejecución", + "auto_reply": "Auto Reply" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "Agentes", "mentionGroupSession": "Sesiones", "mentionGroupCommit": "Commits", - "mentionGroupSkill": "Habilidades" + "mentionGroupSkill": "Habilidades", + "autoReply": "Auto Reply", + "autoReplyEnabledHint": "Auto Reply is on for this conversation" }, "messageQueue": { "addToQueue": "Agregar a la cola", @@ -2632,6 +2635,15 @@ "cardCompleted": "Tarea en segundo plano completada", "cardFinishedWithStatus": "Tarea en segundo plano finalizada ({status})", "cardResultPending": "El resultado aún no ha llegado" + }, + "autoReply": { + "bannerTitle": "Auto-replying \"{reply}\" in {seconds}s", + "bannerMatched": "Matched: {label}", + "cancel": "Cancel", + "stopNotice": "Auto-reply stopped for this interruption (max retries reached).", + "dismiss": "Dismiss", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "Error text: {value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "Descripción y prompt del sistema", "fieldDescription": "Descripción", "baseInstructions": "Prompt del sistema (base_instructions)" + }, + "AutoReplySettings": { + "title": "Auto Reply", + "description": "Configure rules that automatically send a recovery message when error or status signals match. Enable Auto Reply per conversation from the composer + menu.", + "help": "Rules match Claude API retry / connection error signals only (not assistant text). First enabled match wins. Built-in rules can be edited but not deleted.", + "addRule": "Add rule", + "save": "Save", + "delete": "Delete", + "name": "Name", + "enabled": "Enabled", + "matchKind": "Match kind", + "matchValue": "Match value", + "replyText": "Reply text", + "delaySeconds": "Delay (seconds)", + "cooldownSeconds": "Cooldown (seconds)", + "maxPerBurst": "Max per burst", + "builtinBadge": "Built-in", + "cannotDeleteBuiltin": "Built-in rules cannot be deleted.", + "matchKindHttpStatus": "HTTP status", + "matchKindErrorText": "Error text contains", + "empty": "No rules yet", + "moveUp": "Move up", + "moveDown": "Move down", + "saved": "Rules saved", + "confirmDeleteTitle": "Delete rule?", + "confirmDeleteDescription": "This custom auto-reply rule will be removed.", + "cancel": "Cancel", + "newRuleName": "New rule" } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8747ac281..2d319b730 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -53,7 +53,8 @@ "office_tools": "Outils bureautiques", "skill_packs": "Packs de compétences", "quick_messages": "Messages rapides", - "logs": "Journaux d'exécution" + "logs": "Journaux d'exécution", + "auto_reply": "Auto Reply" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "Agents", "mentionGroupSession": "Sessions", "mentionGroupCommit": "Commits", - "mentionGroupSkill": "Compétences" + "mentionGroupSkill": "Compétences", + "autoReply": "Auto Reply", + "autoReplyEnabledHint": "Auto Reply is on for this conversation" }, "messageQueue": { "addToQueue": "Mettre en file", @@ -2632,6 +2635,15 @@ "cardCompleted": "Tâche en arrière-plan terminée", "cardFinishedWithStatus": "Tâche en arrière-plan terminée ({status})", "cardResultPending": "Résultat pas encore reçu" + }, + "autoReply": { + "bannerTitle": "Auto-replying \"{reply}\" in {seconds}s", + "bannerMatched": "Matched: {label}", + "cancel": "Cancel", + "stopNotice": "Auto-reply stopped for this interruption (max retries reached).", + "dismiss": "Dismiss", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "Error text: {value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "Description et prompt système", "fieldDescription": "Description", "baseInstructions": "Invite système (base_instructions)" + }, + "AutoReplySettings": { + "title": "Auto Reply", + "description": "Configure rules that automatically send a recovery message when error or status signals match. Enable Auto Reply per conversation from the composer + menu.", + "help": "Rules match Claude API retry / connection error signals only (not assistant text). First enabled match wins. Built-in rules can be edited but not deleted.", + "addRule": "Add rule", + "save": "Save", + "delete": "Delete", + "name": "Name", + "enabled": "Enabled", + "matchKind": "Match kind", + "matchValue": "Match value", + "replyText": "Reply text", + "delaySeconds": "Delay (seconds)", + "cooldownSeconds": "Cooldown (seconds)", + "maxPerBurst": "Max per burst", + "builtinBadge": "Built-in", + "cannotDeleteBuiltin": "Built-in rules cannot be deleted.", + "matchKindHttpStatus": "HTTP status", + "matchKindErrorText": "Error text contains", + "empty": "No rules yet", + "moveUp": "Move up", + "moveDown": "Move down", + "saved": "Rules saved", + "confirmDeleteTitle": "Delete rule?", + "confirmDeleteDescription": "This custom auto-reply rule will be removed.", + "cancel": "Cancel", + "newRuleName": "New rule" } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e6e9a0d0e..e222c1933 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -53,7 +53,8 @@ "office_tools": "Officeツール", "skill_packs": "スキルパック", "quick_messages": "クイックメッセージ", - "logs": "実行ログ" + "logs": "実行ログ", + "auto_reply": "Auto Reply" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "エージェント", "mentionGroupSession": "セッション", "mentionGroupCommit": "コミット", - "mentionGroupSkill": "スキル" + "mentionGroupSkill": "スキル", + "autoReply": "Auto Reply", + "autoReplyEnabledHint": "Auto Reply is on for this conversation" }, "messageQueue": { "addToQueue": "キューに追加", @@ -2632,6 +2635,15 @@ "cardCompleted": "バックグラウンドタスクが完了しました", "cardFinishedWithStatus": "バックグラウンドタスクが終了しました({status})", "cardResultPending": "結果はまだ返っていません" + }, + "autoReply": { + "bannerTitle": "Auto-replying \"{reply}\" in {seconds}s", + "bannerMatched": "Matched: {label}", + "cancel": "Cancel", + "stopNotice": "Auto-reply stopped for this interruption (max retries reached).", + "dismiss": "Dismiss", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "Error text: {value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "説明とシステムプロンプト", "fieldDescription": "説明", "baseInstructions": "システムプロンプト(base_instructions)" + }, + "AutoReplySettings": { + "title": "Auto Reply", + "description": "Configure rules that automatically send a recovery message when error or status signals match. Enable Auto Reply per conversation from the composer + menu.", + "help": "Rules match Claude API retry / connection error signals only (not assistant text). First enabled match wins. Built-in rules can be edited but not deleted.", + "addRule": "Add rule", + "save": "Save", + "delete": "Delete", + "name": "Name", + "enabled": "Enabled", + "matchKind": "Match kind", + "matchValue": "Match value", + "replyText": "Reply text", + "delaySeconds": "Delay (seconds)", + "cooldownSeconds": "Cooldown (seconds)", + "maxPerBurst": "Max per burst", + "builtinBadge": "Built-in", + "cannotDeleteBuiltin": "Built-in rules cannot be deleted.", + "matchKindHttpStatus": "HTTP status", + "matchKindErrorText": "Error text contains", + "empty": "No rules yet", + "moveUp": "Move up", + "moveDown": "Move down", + "saved": "Rules saved", + "confirmDeleteTitle": "Delete rule?", + "confirmDeleteDescription": "This custom auto-reply rule will be removed.", + "cancel": "Cancel", + "newRuleName": "New rule" } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 70e6c3844..b5b0f2e50 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -53,7 +53,8 @@ "office_tools": "오피스 도구", "skill_packs": "스킬 팩", "quick_messages": "빠른 메시지", - "logs": "실행 로그" + "logs": "실행 로그", + "auto_reply": "Auto Reply" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "에이전트", "mentionGroupSession": "세션", "mentionGroupCommit": "커밋", - "mentionGroupSkill": "스킬" + "mentionGroupSkill": "스킬", + "autoReply": "Auto Reply", + "autoReplyEnabledHint": "Auto Reply is on for this conversation" }, "messageQueue": { "addToQueue": "대기열에 추가", @@ -2632,6 +2635,15 @@ "cardCompleted": "백그라운드 작업 완료", "cardFinishedWithStatus": "백그라운드 작업 종료 ({status})", "cardResultPending": "결과가 아직 반환되지 않았습니다" + }, + "autoReply": { + "bannerTitle": "Auto-replying \"{reply}\" in {seconds}s", + "bannerMatched": "Matched: {label}", + "cancel": "Cancel", + "stopNotice": "Auto-reply stopped for this interruption (max retries reached).", + "dismiss": "Dismiss", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "Error text: {value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "설명 및 시스템 프롬프트", "fieldDescription": "설명", "baseInstructions": "시스템 프롬프트(base_instructions)" + }, + "AutoReplySettings": { + "title": "Auto Reply", + "description": "Configure rules that automatically send a recovery message when error or status signals match. Enable Auto Reply per conversation from the composer + menu.", + "help": "Rules match Claude API retry / connection error signals only (not assistant text). First enabled match wins. Built-in rules can be edited but not deleted.", + "addRule": "Add rule", + "save": "Save", + "delete": "Delete", + "name": "Name", + "enabled": "Enabled", + "matchKind": "Match kind", + "matchValue": "Match value", + "replyText": "Reply text", + "delaySeconds": "Delay (seconds)", + "cooldownSeconds": "Cooldown (seconds)", + "maxPerBurst": "Max per burst", + "builtinBadge": "Built-in", + "cannotDeleteBuiltin": "Built-in rules cannot be deleted.", + "matchKindHttpStatus": "HTTP status", + "matchKindErrorText": "Error text contains", + "empty": "No rules yet", + "moveUp": "Move up", + "moveDown": "Move down", + "saved": "Rules saved", + "confirmDeleteTitle": "Delete rule?", + "confirmDeleteDescription": "This custom auto-reply rule will be removed.", + "cancel": "Cancel", + "newRuleName": "New rule" } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 15941ece7..f023406e7 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -53,7 +53,8 @@ "office_tools": "Ferramentas de escritório", "skill_packs": "Pacotes de habilidades", "quick_messages": "Mensagens rápidas", - "logs": "Registros de execução" + "logs": "Registros de execução", + "auto_reply": "Auto Reply" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "Agentes", "mentionGroupSession": "Sessões", "mentionGroupCommit": "Commits", - "mentionGroupSkill": "Habilidades" + "mentionGroupSkill": "Habilidades", + "autoReply": "Auto Reply", + "autoReplyEnabledHint": "Auto Reply is on for this conversation" }, "messageQueue": { "addToQueue": "Adicionar à fila", @@ -2632,6 +2635,15 @@ "cardCompleted": "Tarefa em segundo plano concluída", "cardFinishedWithStatus": "Tarefa em segundo plano encerrada ({status})", "cardResultPending": "Resultado ainda não retornou" + }, + "autoReply": { + "bannerTitle": "Auto-replying \"{reply}\" in {seconds}s", + "bannerMatched": "Matched: {label}", + "cancel": "Cancel", + "stopNotice": "Auto-reply stopped for this interruption (max retries reached).", + "dismiss": "Dismiss", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "Error text: {value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "Descrição e prompt do sistema", "fieldDescription": "Descrição", "baseInstructions": "Prompt do sistema (base_instructions)" + }, + "AutoReplySettings": { + "title": "Auto Reply", + "description": "Configure rules that automatically send a recovery message when error or status signals match. Enable Auto Reply per conversation from the composer + menu.", + "help": "Rules match Claude API retry / connection error signals only (not assistant text). First enabled match wins. Built-in rules can be edited but not deleted.", + "addRule": "Add rule", + "save": "Save", + "delete": "Delete", + "name": "Name", + "enabled": "Enabled", + "matchKind": "Match kind", + "matchValue": "Match value", + "replyText": "Reply text", + "delaySeconds": "Delay (seconds)", + "cooldownSeconds": "Cooldown (seconds)", + "maxPerBurst": "Max per burst", + "builtinBadge": "Built-in", + "cannotDeleteBuiltin": "Built-in rules cannot be deleted.", + "matchKindHttpStatus": "HTTP status", + "matchKindErrorText": "Error text contains", + "empty": "No rules yet", + "moveUp": "Move up", + "moveDown": "Move down", + "saved": "Rules saved", + "confirmDeleteTitle": "Delete rule?", + "confirmDeleteDescription": "This custom auto-reply rule will be removed.", + "cancel": "Cancel", + "newRuleName": "New rule" } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 656c04865..702fad90a 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -53,7 +53,8 @@ "office_tools": "办公工具", "skill_packs": "技能包", "quick_messages": "快捷消息", - "logs": "运行日志" + "logs": "运行日志", + "auto_reply": "自动回复" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "智能体", "mentionGroupSession": "会话", "mentionGroupCommit": "提交", - "mentionGroupSkill": "技能" + "mentionGroupSkill": "技能", + "autoReply": "自动回复", + "autoReplyEnabledHint": "当前会话已开启自动回复" }, "messageQueue": { "addToQueue": "加入队列", @@ -2632,6 +2635,15 @@ "cardCompleted": "后台任务已完成", "cardFinishedWithStatus": "后台任务已结束({status})", "cardResultPending": "结果尚未返回" + }, + "autoReply": { + "bannerTitle": "即将自动回复「{reply}」· {seconds}s", + "bannerMatched": "匹配:{label}", + "cancel": "取消", + "stopNotice": "此中断的自动回复已停止(已达次数上限)。", + "dismiss": "关闭", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "错误文本:{value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "描述与系统提示词", "fieldDescription": "描述", "baseInstructions": "系统提示词(base_instructions)" + }, + "AutoReplySettings": { + "title": "自动回复", + "description": "配置在错误/状态信号匹配时自动发送恢复消息的规则。请在输入框 + 菜单中按会话开启自动回复。", + "help": "规则仅匹配 Claude API 重试 / 连接错误信号(不匹配助手正文)。按列表顺序取第一个启用的规则。内置规则可编辑但不可删除。", + "addRule": "添加规则", + "save": "保存", + "delete": "删除", + "name": "名称", + "enabled": "启用", + "matchKind": "匹配类型", + "matchValue": "匹配值", + "replyText": "回复内容", + "delaySeconds": "延迟(秒)", + "cooldownSeconds": "冷却(秒)", + "maxPerBurst": "单次中断最大次数", + "builtinBadge": "内置", + "cannotDeleteBuiltin": "内置规则不可删除。", + "matchKindHttpStatus": "HTTP 状态码", + "matchKindErrorText": "错误文本包含", + "empty": "暂无规则", + "moveUp": "上移", + "moveDown": "下移", + "saved": "规则已保存", + "confirmDeleteTitle": "删除规则?", + "confirmDeleteDescription": "将删除该自定义自动回复规则。", + "cancel": "取消", + "newRuleName": "新规则" } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 34a7d3d2b..8776eb28f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -53,7 +53,8 @@ "office_tools": "辦公工具", "skill_packs": "技能包", "quick_messages": "快捷訊息", - "logs": "執行日誌" + "logs": "執行日誌", + "auto_reply": "自动回复" } }, "AppearanceSettings": { @@ -2141,7 +2142,9 @@ "mentionGroupAgent": "智能體", "mentionGroupSession": "工作階段", "mentionGroupCommit": "提交", - "mentionGroupSkill": "技能" + "mentionGroupSkill": "技能", + "autoReply": "自动回复", + "autoReplyEnabledHint": "当前会话已开启自动回复" }, "messageQueue": { "addToQueue": "加入佇列", @@ -2632,6 +2635,15 @@ "cardCompleted": "背景任務已完成", "cardFinishedWithStatus": "背景任務已結束({status})", "cardResultPending": "結果尚未返回" + }, + "autoReply": { + "bannerTitle": "即将自动回复「{reply}」· {seconds}s", + "bannerMatched": "匹配:{label}", + "cancel": "取消", + "stopNotice": "此中断的自动回复已停止(已达次数上限)。", + "dismiss": "关闭", + "matchedHttpStatus": "HTTP {status}", + "matchedErrorText": "错误文本:{value}" } }, "diffPreview": { @@ -3830,5 +3842,33 @@ "groupInstructions": "描述與系統提示詞", "fieldDescription": "描述", "baseInstructions": "系統提示詞(base_instructions)" + }, + "AutoReplySettings": { + "title": "自动回复", + "description": "配置在错误/状态信号匹配时自动发送恢复消息的规则。请在输入框 + 菜单中按会话开启自动回复。", + "help": "规则仅匹配 Claude API 重试 / 连接错误信号(不匹配助手正文)。按列表顺序取第一个启用的规则。内置规则可编辑但不可删除。", + "addRule": "添加规则", + "save": "保存", + "delete": "删除", + "name": "名称", + "enabled": "启用", + "matchKind": "匹配类型", + "matchValue": "匹配值", + "replyText": "回复内容", + "delaySeconds": "延迟(秒)", + "cooldownSeconds": "冷却(秒)", + "maxPerBurst": "单次中断最大次数", + "builtinBadge": "内置", + "cannotDeleteBuiltin": "内置规则不可删除。", + "matchKindHttpStatus": "HTTP 状态码", + "matchKindErrorText": "错误文本包含", + "empty": "暂无规则", + "moveUp": "上移", + "moveDown": "下移", + "saved": "规则已保存", + "confirmDeleteTitle": "删除规则?", + "confirmDeleteDescription": "将删除该自定义自动回复规则。", + "cancel": "取消", + "newRuleName": "新规则" } } From 285bfd7a2af9645726272d27eb76fdb9606ffd8b Mon Sep 17 00:00:00 2001 From: jry Date: Wed, 15 Jul 2026 09:14:21 +0800 Subject: [PATCH 5/7] feat(auto-reply): wire countdown banner and + menu toggle Host the auto-reply engine in the conversation shell, show a pre-send countdown banner above the composer, cancel pending replies on manual send/enqueue, and expose a per-conversation enable switch in the + menu. --- src/components/chat/auto-reply-banner.tsx | 96 ++++++++++++++++++++++ src/components/chat/chat-input.tsx | 6 ++ src/components/chat/conversation-shell.tsx | 78 +++++++++++++++++- src/components/chat/message-input.tsx | 27 +++++- 4 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 src/components/chat/auto-reply-banner.tsx diff --git a/src/components/chat/auto-reply-banner.tsx b/src/components/chat/auto-reply-banner.tsx new file mode 100644 index 000000000..088af5bb2 --- /dev/null +++ b/src/components/chat/auto-reply-banner.tsx @@ -0,0 +1,96 @@ +"use client" + +import { useTranslations } from "next-intl" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import type { + AutoReplyPendingState, + AutoReplyStopNotice, +} from "@/hooks/use-auto-reply-engine" + +interface AutoReplyBannerProps { + pending: AutoReplyPendingState | null + stopNotice: AutoReplyStopNotice | null + onCancel: () => void + onDismissStopNotice: () => void + className?: string +} + +export function AutoReplyBanner({ + pending, + stopNotice, + onCancel, + onDismissStopNotice, + className, +}: AutoReplyBannerProps) { + const t = useTranslations("Folder.chat.autoReply") + + if (pending) { + const seconds = Math.max(1, Math.ceil(pending.remainingMs / 1000)) + return ( +
+
+
+
+ {t("bannerTitle", { + reply: pending.replyText, + seconds, + })} +
+
+ {t("bannerMatched", { label: pending.matchedLabel })} +
+
+ +
+
+ ) + } + + if (stopNotice) { + return ( +
+
+
+
{t("stopNotice")}
+
+ {t("bannerMatched", { label: stopNotice.matchedLabel })} +
+
+ +
+
+ ) + } + + return null +} diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index cfcbdacad..2e82acc18 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -58,6 +58,8 @@ interface ChatInputProps { onForkSend?: (draft: PromptDraft, modeId?: string | null) => void onAddFeedback?: () => void feedbackAddDisabled?: boolean + autoReplyEnabled?: boolean + onAutoReplyEnabledChange?: (enabled: boolean) => void /** * Keep the composer usable even while disconnected. Set for a folderless chat * draft: it has no working dir yet (so it never auto-connects), and the FIRST @@ -113,6 +115,8 @@ export const ChatInput = memo(function ChatInput({ onForkSend, onAddFeedback, feedbackAddDisabled, + autoReplyEnabled = false, + onAutoReplyEnabledChange, allowOfflineCompose = false, injectContent, onInjectConsumed, @@ -177,6 +181,8 @@ export const ChatInput = memo(function ChatInput({ onForkSend={onForkSend} onAddFeedback={onAddFeedback} feedbackAddDisabled={feedbackAddDisabled} + autoReplyEnabled={autoReplyEnabled} + onAutoReplyEnabledChange={onAutoReplyEnabledChange} injectContent={injectContent} onInjectConsumed={onInjectConsumed} placeholder={ diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx index c976af0bf..8b45f3420 100644 --- a/src/components/chat/conversation-shell.tsx +++ b/src/components/chat/conversation-shell.tsx @@ -1,4 +1,4 @@ -import { useMemo, type ReactNode } from "react" +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" import { useTranslations } from "next-intl" import type { AgentType, @@ -23,6 +23,12 @@ import { ChatInput } from "@/components/chat/chat-input" import { PermissionDialog } from "@/components/chat/permission-dialog" import { QuestionDialog } from "@/components/chat/question-dialog" import { AskQuestionCard } from "@/components/chat/ask-question-card" +import { AutoReplyBanner } from "@/components/chat/auto-reply-banner" +import { useAutoReplyEngine } from "@/hooks/use-auto-reply-engine" +import { + isAutoReplyEnabled, + setAutoReplyEnabled, +} from "@/lib/auto-reply/storage" interface ConversationShellProps { status: ConnectionStatus | null @@ -194,6 +200,62 @@ export function ConversationShell({ }) }, [claudeApiRetry, tAcp]) + const enableKey = draftStorageKey ?? "unknown" + const [autoReplyEnabled, setAutoReplyEnabledState] = useState(false) + + useEffect(() => { + setAutoReplyEnabledState(isAutoReplyEnabled(enableKey)) + }, [enableKey]) + + const handleAutoReplyEnabledChange = useCallback( + (enabled: boolean) => { + setAutoReplyEnabled(enableKey, enabled) + setAutoReplyEnabledState(enabled) + }, + [enableKey] + ) + + const handleAutoSend = useCallback( + (draft: PromptDraft) => { + onSend(draft) + }, + [onSend] + ) + + const { + pending: autoReplyPending, + stopNotice: autoReplyStopNotice, + cancelPending: cancelAutoReplyPending, + notifyManualSend: notifyAutoReplyManualSend, + dismissStopNotice: dismissAutoReplyStopNotice, + } = useAutoReplyEngine({ + enabled: autoReplyEnabled, + status, + error, + claudeApiRetry, + pendingPermission: pendingPermission != null, + pendingQuestion: pendingQuestion != null, + pendingAskQuestion: + pendingAskQuestion != null && pendingAskQuestion.questions.length > 0, + onSend: handleAutoSend, + }) + + const handleComposerSend = useCallback( + (draft: PromptDraft, modeId?: string | null) => { + notifyAutoReplyManualSend() + onSend(draft, modeId) + }, + [notifyAutoReplyManualSend, onSend] + ) + + const handleComposerEnqueue = useCallback( + (draft: PromptDraft, modeId: string | null) => { + notifyAutoReplyManualSend() + onEnqueue?.(draft, modeId) + }, + [notifyAutoReplyManualSend, onEnqueue] + ) + return (
{topBanner} @@ -226,14 +288,24 @@ export function ConversationShell({ {!hideInput && (
+
+ +