diff --git a/docs/superpowers/specs/2026-07-15-repeat-intent-detection-design.md b/docs/superpowers/specs/2026-07-15-repeat-intent-detection-design.md new file mode 100644 index 000000000..031e9ef7e --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-repeat-intent-detection-design.md @@ -0,0 +1,243 @@ +# Repeat Intent Detection Design + +**Date:** 2026-07-15 +**Status:** Approved for implementation planning +**Scope:** Chat composer send path + pending message queue (frontend only) +**Approach:** Pure trailing-multiplier parser + confirm dialog + bulk enqueue + +## Problem + +Users often want to queue the same short follow-up many times (for example `继续` / `continue`) while an agent is working through a multi-step task. Typing or clicking send repeatedly is tedious. A common natural shorthand is: + +```text +继续x10 +继续 X10 +continue x 5 +``` + +Codeg already has a pending-message queue (`useMessageQueue`) and auto-flush when a turn becomes free. What is missing is recognition of the trailing `xN` / `XN` multiplier and a confirmation step that expands it into N queued drafts. + +## Goals + +1. Detect a trailing repeat multiplier on composer submit. +2. Support `x` and `X`, with optional spaces around the multiplier. +3. Match only a **trailing** suffix (not whole-string-only, not mid-text). +4. Show a confirmation dialog before expanding. +5. On confirm, enqueue **N identical base messages** into the existing pending queue. +6. On cancel/dismiss, keep the original composer text unchanged. +7. Cap N to a safe range: **2..50**. +8. Cover the behavior with unit tests and i18n (EN + zh-CN minimum; all locales if key parity is enforced). + +## Non-goals + +- Live detection while typing / inline chips. +- Direct multi-send that bypasses the queue. +- Multipliers other than `x` / `X` (`*10`, `×10`, `x10次`, etc.). +- Persisted "always expand" preference. +- Backend/Rust changes. +- Changing auto-flush timing or queue FIFO semantics. + +## Decisions (from product clarification) + +| Topic | Decision | +| --- | --- | +| Match scope | Trailing multiplier suffix only | +| Confirm action | Enqueue all N items into pending queue | +| Cancel action | Close dialog only; keep original text including `xN` | +| Valid N | Integer in **2..50** inclusive | +| Idle vs busy | Always enqueue N; do not special-case direct first send | + +## Detection rules + +### Input source + +On send, parse the draft's plain `displayText` after normal draft construction. + +Do **not** run detection when: + +- composer is in queue-item edit mode +- `buildDraft()` fails / returns empty +- `onEnqueue` / bulk-enqueue path is unavailable (fall back to normal single send of the original draft) + +### Pattern + +Conceptual regex against the full display text: + +```text +^(?s)(.+?)[ \t]*[xX][ \t]*(\d+)[ \t]*$ +``` + +Rules: + +1. Trim only trailing whitespace of the whole input before matching if needed; keep internal spaces inside the base text. +2. `baseText` = capture group 1, right-trimmed; must be non-empty. +3. `count` = integer from capture group 2. +4. Trigger only when `2 <= count <= 50`. +5. Multiplier must be trailing; anything after the number fails the match. + +### Positive examples + +| Input | baseText | count | +| --- | --- | --- | +| `继续X10` | `继续` | 10 | +| `继续x10` | `继续` | 10 | +| `继续 X10` | `继续` | 10 | +| `继续 x 10` | `继续` | 10 | +| `continue X 3` | `continue` | 3 | +| `请继续修复x10` | `请继续修复` | 10 | +| `fix this\nplease x2` | `fix this\nplease` | 2 | + +### Negative examples + +| Input | Reason | +| --- | --- | +| `继续x1` | below min | +| `继续x51` | above max | +| `x10` | empty base text | +| `继续X10 请` | multiplier not trailing | +| `继续X10a` | non-digit tail | +| `继续*10` | unsupported operator | +| attachment-only empty text | no textual multiplier | + +### Rich drafts / attachments + +- Multiplier is detected only from trailing **text**. +- On confirm, strip the multiplier from text (`displayText` and text blocks). +- Keep image/resource/reference blocks unchanged. +- If stripping leaves empty prose but attachments remain, the resulting draft is still valid (same as normal attachment-only drafts). + +## Interaction flow + +```text +User presses Send / Enter + | + v +buildDraft() + | + +-- queue edit mode? --> save edit (no repeat detect) + | + v +parseRepeatIntent(displayText) + | + +-- no match / out of range --> existing send or single enqueue + | + v +open AlertDialog (composer text kept) + | + +-- Cancel / Esc / dismiss --> close only + | + v +Confirm + - build base draft (suffix stripped) + - enqueueMany(baseDraft, modeId, count) + - clear composer + - existing queue UI + auto-flush handle delivery +``` + +### Dialog copy + +i18n keys under the chat/message-input namespace: + +- Title: "Queue repeated messages?" / "生成重复待发送消息?" +- Description: include `count` and a short preview of `baseText` +- Confirm: "Queue N messages" / "生成 N 条待发送" +- Cancel: reuse existing cancel wording where possible + +### Enqueue semantics + +- Always enqueue **N** copies of the base draft. +- Do **not** direct-send the first item even when the agent is idle. +- Prefer one atomic queue commit (`enqueueMany`) to avoid N React state updates and intermediate renders. +- Each queued item gets its own id (existing `randomUUID()` behavior). +- Mode id uses the same value the normal enqueue path would use. + +If bulk enqueue plumbing is unavailable on a surface, fall back to normal single send of the **original** unsplit draft. Never silently multi-send outside the queue. + +## Architecture + +### Components + +1. **`src/lib/repeat-intent.ts`** (pure) + - `MIN_REPEAT_COUNT = 2` + - `MAX_REPEAT_COUNT = 50` + - `parseRepeatIntent(text: string): { baseText: string; count: number } | null` + - helper(s) to strip the trailing multiplier from draft text/blocks + +2. **`src/lib/repeat-intent.test.ts`** + - spaces, case, bounds, unicode base text, newlines, non-matches + +3. **`src/hooks/use-message-queue.ts`** + - add `enqueueMany(draft, modeId, count)` + - single `commit([...queue, ...newItems])` + - unit coverage for atomic append + count clamp/guard (`count < 1` no-op) + +4. **`src/components/chat/message-input.tsx`** + - intercept in `handleSend` before normal send/enqueue + - local dialog state: open flag + pending `{ draft, modeId, baseText, count }` + - render shared `AlertDialog` on confirm/cancel + +5. **Parent wiring** + - `conversation-detail-panel` already owns `useMessageQueue` + - pass bulk enqueue down through `chat-input` / `conversation-shell` as `onEnqueueMany` (or equivalent) + - keep existing `onEnqueue` for single-item paths + +6. **i18n** + - `en.json`, `zh-CN.json`, and any other locales required by key-parity tests + +### Data flow + +```text +MessageInput.handleSend + -> parseRepeatIntent + -> (confirm) + -> onEnqueueMany(baseDraft, modeId, count) + -> useMessageQueue.enqueueMany + -> MessageQueueDisplay + existing auto-flush +``` + +### Error handling + +- Invalid/out-of-range count: treat as normal message, no dialog. +- Dialog open while status changes: cancel keeps text; confirm still only enqueues (safe if agent becomes idle/busy). +- No new backend error paths. + +## Testing plan + +1. **Parser unit tests** + - all positive/negative examples above + - leading/trailing spaces around `x` and digits + - multiline base text with trailing multiplier + +2. **Queue unit tests** + - `enqueueMany` appends N items with identical draft/modeId and unique ids + - does not mutate previous queue order + - `count <= 0` is a no-op + +3. **Composer behavior tests** (message-input or focused helper tests) + - matching text opens dialog and does not enqueue yet + - confirm enqueues N and clears composer + - cancel leaves original text + - queue-edit mode skips detection + - out-of-range sends/enqueues once through normal path + +## Implementation notes + +- Prefer pure helpers over embedding regex in the React component. +- Reuse existing `AlertDialog` primitives (`src/components/ui/alert-dialog.tsx`). +- Keep the feature frontend-only; no Tauri/command changes. +- Follow Prettier/ESLint project style (no semicolons, etc.). +- Work from a fresh branch off `main`, not the current Tailscale funnel branch. + +## PR expectations + +- Feature branch: `feat/repeat-intent-queue` (or similar) +- PR body includes **English + 中文** summary, behavior, test plan, and screenshots/GIFs if UI is easy to capture +- Target repository: `xintaofei/codeg` (use fork remote if origin push is unavailable) + +## Open questions + +None remaining for v1. Future optional enhancements (out of scope now): + +- remember "don't ask again" for the session +- custom max count in settings +- support for `×` / `*` multipliers \ No newline at end of file diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index cfcbdacad..cdd9a0401 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -46,6 +46,11 @@ interface ChatInputProps { showActiveFlow?: boolean queue?: QueuedMessage[] onEnqueue?: (draft: PromptDraft, modeId: string | null) => void + onEnqueueMany?: ( + draft: PromptDraft, + modeId: string | null, + count: number + ) => void onQueueReorder?: (items: QueuedMessage[]) => void onQueueEdit?: (id: string) => void onQueueDelete?: (id: string) => void @@ -101,6 +106,7 @@ export const ChatInput = memo(function ChatInput({ showActiveFlow, queue, onEnqueue, + onEnqueueMany, onQueueReorder, onQueueEdit, onQueueDelete, @@ -168,6 +174,7 @@ export const ChatInput = memo(function ChatInput({ isActive={isActive} showActiveFlow={showActiveFlow} onEnqueue={onEnqueue} + onEnqueueMany={onEnqueueMany} editingItemId={editingItemId} editingDraftText={editingDraftText} editingDraftBlocks={editingDraftBlocks} diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx index c976af0bf..1921e09e4 100644 --- a/src/components/chat/conversation-shell.tsx +++ b/src/components/chat/conversation-shell.tsx @@ -73,6 +73,11 @@ interface ConversationShellProps { showActiveFlow?: boolean queue?: QueuedMessage[] onEnqueue?: (draft: PromptDraft, modeId: string | null) => void + onEnqueueMany?: ( + draft: PromptDraft, + modeId: string | null, + count: number + ) => void onQueueReorder?: (items: QueuedMessage[]) => void onQueueEdit?: (id: string) => void onQueueDelete?: (id: string) => void @@ -126,6 +131,7 @@ export function ConversationShell({ showActiveFlow, queue, onEnqueue, + onEnqueueMany, onQueueReorder, onQueueEdit, onQueueDelete, @@ -250,6 +256,7 @@ export function ConversationShell({ showActiveFlow={showActiveFlow} queue={queue} onEnqueue={onEnqueue} + onEnqueueMany={onEnqueueMany} onQueueReorder={onQueueReorder} onQueueEdit={onQueueEdit} onQueueDelete={onQueueDelete} diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 1ac49c5c3..bca293999 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -424,3 +424,127 @@ describe("MessageInput collapsed selectors popover", () => { ) }) }) + +describe("MessageInput repeat intent", () => { + afterEach(() => { + cleanup() + composerHandle.current = null + }) + + async function mountComposer( + props: Partial> = {} + ) { + const utils = renderInput(props) + await waitFor( + () => expect(composerHandle.current?.getEditor()).toBeTruthy(), + { timeout: 5000 } + ) + return utils + } + + async function setComposerText(text: string) { + await act(async () => { + composerHandle.current?.setText(text) + }) + } + + function clickSend(container: HTMLElement) { + const sendButton = container.querySelector( + `button[title="${enMessages.Folder.chat.messageInput.send}"]` + ) + expect(sendButton).not.toBeNull() + expect(sendButton).not.toBeDisabled() + fireEvent.click(sendButton!) + } + + it("opens a confirm dialog for trailing xN and does not enqueue yet", async () => { + const onEnqueueMany = vi.fn() + const onSend = vi.fn() + const { container } = await mountComposer({ onEnqueueMany, onSend }) + await setComposerText("继续x10") + clickSend(container) + + expect( + await screen.findByRole("alertdialog", { + name: enMessages.Folder.chat.messageQueue.repeatIntentTitle, + }) + ).toBeInTheDocument() + expect(onEnqueueMany).not.toHaveBeenCalled() + expect(onSend).not.toHaveBeenCalled() + }) + + it("enqueues N base drafts and clears the composer on confirm", async () => { + const user = userEvent.setup() + const onEnqueueMany = vi.fn() + const { container } = await mountComposer({ onEnqueueMany }) + await setComposerText("继续 x 3") + clickSend(container) + + await screen.findByRole("alertdialog", { + name: enMessages.Folder.chat.messageQueue.repeatIntentTitle, + }) + await user.click( + screen.getByRole("button", { + name: enMessages.Folder.chat.messageQueue.repeatIntentConfirm.replace( + "{count}", + "3" + ), + }) + ) + + await waitFor(() => expect(onEnqueueMany).toHaveBeenCalledTimes(1)) + const [draft, modeId, count] = onEnqueueMany.mock.calls[0] + expect(draft.displayText).toBe("继续") + expect(modeId).toBeNull() + expect(count).toBe(3) + await waitFor(() => + expect(composerHandle.current?.getText().trim() ?? "").toBe("") + ) + }) + + it("keeps the original text when the dialog is cancelled", async () => { + const user = userEvent.setup() + const onEnqueueMany = vi.fn() + const { container } = await mountComposer({ onEnqueueMany }) + await setComposerText("continue X 4") + clickSend(container) + + await screen.findByRole("alertdialog", { + name: enMessages.Folder.chat.messageQueue.repeatIntentTitle, + }) + await user.click( + screen.getByRole("button", { + name: enMessages.Folder.chat.messageQueue.repeatIntentCancel, + }) + ) + + await waitFor(() => + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument() + ) + expect(onEnqueueMany).not.toHaveBeenCalled() + expect(composerHandle.current?.getText()).toContain("continue X 4") + }) + + it("skips detection while editing a queue item", async () => { + const onSaveQueueEdit = vi.fn() + const onEnqueueMany = vi.fn() + const { container } = await mountComposer({ + isEditingQueueItem: true, + editingItemId: "q1", + editingDraftText: "继续x10", + onSaveQueueEdit, + onEnqueueMany, + }) + await waitFor(() => + expect(composerHandle.current?.getText() ?? "").toContain("继续") + ) + const saveButton = container.querySelector( + `button[title="${enMessages.Folder.chat.messageQueue.saveEdit}"]` + ) + expect(saveButton).not.toBeNull() + fireEvent.click(saveButton!) + await waitFor(() => expect(onSaveQueueEdit).toHaveBeenCalledTimes(1)) + expect(onEnqueueMany).not.toHaveBeenCalled() + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument() + }) +}) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index f619ac0d1..97570a7f6 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -56,6 +56,17 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu" import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { applyRepeatBaseText, parseRepeatIntent } from "@/lib/repeat-intent" import { AgentIcon } from "@/components/agent-icon" import { cn, copyTextFromMenu, randomUUID } from "@/lib/utils" import { @@ -220,6 +231,11 @@ interface MessageInputProps { * `isActive` (which still drives auto-focus/connect). */ showActiveFlow?: boolean onEnqueue?: (draft: PromptDraft, modeId: string | null) => void + onEnqueueMany?: ( + draft: PromptDraft, + modeId: string | null, + count: number + ) => void /** Id of the queue item being edited — the stable key for (re)hydration, so * switching between two items with identical display text still reloads. */ editingItemId?: string | null @@ -503,6 +519,7 @@ export function MessageInput({ isActive = false, showActiveFlow = false, onEnqueue, + onEnqueueMany, editingItemId, editingDraftText, editingDraftBlocks, @@ -2348,6 +2365,15 @@ export function MessageInput({ setAttachments((prev) => prev.filter((item) => item.id !== id)) }, []) + type PendingRepeatIntent = { + baseDraft: PromptDraft + modeId: string | null + baseText: string + count: number + } + const [pendingRepeat, setPendingRepeat] = + useState(null) + const buildDraft = useCallback((): PromptDraft | null => { const editor = editorRef.current?.getEditor() // Authoritative prefix normalization at the send boundary. A skill / expert @@ -2433,14 +2459,30 @@ export function MessageInput({ return } + const modeId = showModeSelector ? effectiveModeId : null + // Trailing xN / XN multiplier → confirm before bulk-enqueueing N base drafts. + // Skip when neither enqueue path exists (fall through to normal single send). + if (onEnqueueMany || onEnqueue) { + const intent = parseRepeatIntent(draft.displayText) + if (intent) { + setPendingRepeat({ + baseDraft: applyRepeatBaseText(draft, intent.baseText), + modeId, + baseText: intent.baseText, + count: intent.count, + }) + return + } + } + // Prompting mode: enqueue instead of sending if (isPrompting && onEnqueue) { - onEnqueue(draft, showModeSelector ? effectiveModeId : null) + onEnqueue(draft, modeId) resetComposer() return } - onSend(draft, showModeSelector ? effectiveModeId : null) + onSend(draft, modeId) if (effectiveDraftStorageKey) { clearMessageInputDraftV2(effectiveDraftStorageKey) } @@ -2452,6 +2494,7 @@ export function MessageInput({ isPrompting, onSaveQueueEdit, onEnqueue, + onEnqueueMany, onSend, effectiveModeId, showModeSelector, @@ -2459,6 +2502,29 @@ export function MessageInput({ resetComposer, ]) + const confirmRepeatIntent = useCallback(() => { + if (!pendingRepeat) return + const { baseDraft, modeId, count } = pendingRepeat + if (onEnqueueMany) { + onEnqueueMany(baseDraft, modeId, count) + } else if (onEnqueue) { + for (let i = 0; i < count; i += 1) { + onEnqueue(baseDraft, modeId) + } + } + setPendingRepeat(null) + if (effectiveDraftStorageKey) { + clearMessageInputDraftV2(effectiveDraftStorageKey) + } + resetComposer() + }, [ + pendingRepeat, + onEnqueueMany, + onEnqueue, + effectiveDraftStorageKey, + resetComposer, + ]) + const handleForkSendClick = useCallback(() => { if (!onForkSend) return const draft = buildDraft() @@ -2850,6 +2916,38 @@ export function MessageInput({ onDragLeave={handleContainerDragLeave} onDrop={handleContainerDrop} > + { + if (!open) setPendingRepeat(null) + }} + > + + + {tQueue("repeatIntentTitle")} + + {pendingRepeat + ? tQueue("repeatIntentDescription", { + count: pendingRepeat.count, + text: pendingRepeat.baseText, + }) + : null} + + + + + {tQueue("repeatIntentCancel")} + + + {pendingRepeat + ? tQueue("repeatIntentConfirm", { + count: pendingRepeat.count, + }) + : null} + + + + {slashMenuOpen && slashAutocompleteCount > 0 && (
{/* No search box: the user types the filter inline after `/` (like the diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 1e133208a..6d3888903 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -510,6 +510,7 @@ const ConversationTabView = memo(function ConversationTabView({ const { queue: msgQueue, enqueue: mqEnqueue, + enqueueMany: mqEnqueueMany, requeueFront: mqRequeueFront, getQueueLength: mqGetQueueLength, dequeue: mqDequeue, @@ -1444,6 +1445,7 @@ const ConversationTabView = memo(function ConversationTabView({ showActiveFlow={showActiveFlow} queue={msgQueue} onEnqueue={mqEnqueue} + onEnqueueMany={mqEnqueueMany} onQueueReorder={mqReorder} onQueueEdit={handleQueueEdit} onQueueDelete={mqRemove} diff --git a/src/hooks/use-message-queue.test.ts b/src/hooks/use-message-queue.test.ts index 13d2f812d..7119d12c1 100644 --- a/src/hooks/use-message-queue.test.ts +++ b/src/hooks/use-message-queue.test.ts @@ -115,3 +115,31 @@ describe("useMessageQueue bounce FIFO ordering", () => { expect(texts(result.current.queue)).toEqual(["B", "A-edited"]) }) }) + +describe("useMessageQueue enqueueMany", () => { + it("appends N identical drafts with unique ids in one update", () => { + const { result } = renderHook(() => useMessageQueue()) + act(() => result.current.enqueue(draft("existing"), null)) + act(() => result.current.enqueueMany(draft("继续"), "code", 3)) + expect(texts(result.current.queue)).toEqual([ + "existing", + "继续", + "继续", + "继续", + ]) + const ids = result.current.queue.map((item) => item.id) + expect(new Set(ids).size).toBe(4) + expect( + result.current.queue.slice(1).every((item) => item.modeId === "code") + ).toBe(true) + expect(result.current.getQueueLength()).toBe(4) + }) + + it("is a no-op for non-positive count", () => { + const { result } = renderHook(() => useMessageQueue()) + act(() => result.current.enqueueMany(draft("x"), null, 0)) + act(() => result.current.enqueueMany(draft("x"), null, -2)) + expect(result.current.queue).toEqual([]) + expect(result.current.getQueueLength()).toBe(0) + }) +}) diff --git a/src/hooks/use-message-queue.ts b/src/hooks/use-message-queue.ts index fac23be16..6b1415b35 100644 --- a/src/hooks/use-message-queue.ts +++ b/src/hooks/use-message-queue.ts @@ -13,6 +13,11 @@ export interface QueuedMessage { export interface UseMessageQueueReturn { queue: QueuedMessage[] enqueue: (draft: PromptDraft, modeId: string | null) => void + enqueueMany: ( + draft: PromptDraft, + modeId: string | null, + count: number + ) => void /** * Put a draft back at the FRONT of the queue. Used when an auto-flushed item * was dequeued, sent, and bounced (TurnBusyError): it must return to the head @@ -63,6 +68,20 @@ export function useMessageQueue(): UseMessageQueueReturn { [commit] ) + const enqueueMany = useCallback( + (draft: PromptDraft, modeId: string | null, count: number) => { + const n = Math.trunc(count) + if (!Number.isFinite(n) || n <= 0) return + const additions = Array.from({ length: n }, () => ({ + id: randomUUID(), + draft, + modeId, + })) + commit([...queueRef.current, ...additions]) + }, + [commit] + ) + const requeueFront = useCallback( (draft: PromptDraft, modeId: string | null) => { commit([{ id: randomUUID(), draft, modeId }, ...queueRef.current]) @@ -138,6 +157,7 @@ export function useMessageQueue(): UseMessageQueueReturn { return { queue, enqueue, + enqueueMany, requeueFront, dequeue, remove, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 18044c1da..2c3dc6d9c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2148,7 +2148,11 @@ "saveEdit": "حفظ", "cancelEdit": "إلغاء التعديل", "editItem": "تعديل", - "deleteItem": "حذف" + "deleteItem": "حذف", + "repeatIntentTitle": "وضع رسائل مكررة في قائمة الانتظار؟", + "repeatIntentDescription": "إنشاء {count} رسائل معلّقة بالمحتوى: \"{text}\"؟", + "repeatIntentConfirm": "وضع {count} رسائل في الانتظار", + "repeatIntentCancel": "إلغاء" }, "welcomeInputPanel": { "agentsSettingsPath": "الإعدادات > الوكلاء", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 953805e00..36a106c59 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2148,7 +2148,11 @@ "saveEdit": "Speichern", "cancelEdit": "Bearbeitung abbrechen", "editItem": "Bearbeiten", - "deleteItem": "Entfernen" + "deleteItem": "Entfernen", + "repeatIntentTitle": "Wiederholte Nachrichten einreihen?", + "repeatIntentDescription": "{count} ausstehende Nachrichten mit: \"{text}\" erstellen?", + "repeatIntentConfirm": "{count} Nachrichten einreihen", + "repeatIntentCancel": "Abbrechen" }, "welcomeInputPanel": { "agentsSettingsPath": "Einstellungen > Agenten", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 16cef83ec..cac02e366 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2148,7 +2148,11 @@ "saveEdit": "Save", "cancelEdit": "Cancel edit", "editItem": "Edit", - "deleteItem": "Remove" + "deleteItem": "Remove", + "repeatIntentTitle": "Queue repeated messages?", + "repeatIntentDescription": "Create {count} pending messages with: \"{text}\"", + "repeatIntentConfirm": "Queue {count} messages", + "repeatIntentCancel": "Cancel" }, "welcomeInputPanel": { "agentsSettingsPath": "Settings > Agents", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1e946a224..5eb25f37e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2148,7 +2148,11 @@ "saveEdit": "Guardar", "cancelEdit": "Cancelar edición", "editItem": "Editar", - "deleteItem": "Eliminar" + "deleteItem": "Eliminar", + "repeatIntentTitle": "¿Poner mensajes repetidos en cola?", + "repeatIntentDescription": "¿Crear {count} mensajes pendientes con: \"{text}\"?", + "repeatIntentConfirm": "Encolar {count} mensajes", + "repeatIntentCancel": "Cancelar" }, "welcomeInputPanel": { "agentsSettingsPath": "Ajustes > Agentes", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8747ac281..3b8715f45 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2148,7 +2148,11 @@ "saveEdit": "Enregistrer", "cancelEdit": "Annuler la modification", "editItem": "Modifier", - "deleteItem": "Supprimer" + "deleteItem": "Supprimer", + "repeatIntentTitle": "Mettre des messages répétés en file ?", + "repeatIntentDescription": "Créer {count} messages en attente avec : \"{text}\" ?", + "repeatIntentConfirm": "Mettre {count} messages en file", + "repeatIntentCancel": "Annuler" }, "welcomeInputPanel": { "agentsSettingsPath": "Paramètres > Agents", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e6e9a0d0e..1bca15179 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2148,7 +2148,11 @@ "saveEdit": "保存", "cancelEdit": "編集をキャンセル", "editItem": "編集", - "deleteItem": "削除" + "deleteItem": "削除", + "repeatIntentTitle": "同じメッセージをキューに入れますか?", + "repeatIntentDescription": "「{text}」を {count} 件の送信待ちとして作成しますか?", + "repeatIntentConfirm": "{count} 件をキューに追加", + "repeatIntentCancel": "キャンセル" }, "welcomeInputPanel": { "agentsSettingsPath": "設定 > エージェント", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 70e6c3844..d74b74cc5 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2148,7 +2148,11 @@ "saveEdit": "저장", "cancelEdit": "편집 취소", "editItem": "편집", - "deleteItem": "삭제" + "deleteItem": "삭제", + "repeatIntentTitle": "반복 메시지를 대기열에 넣을까요?", + "repeatIntentDescription": "「{text}」내용으로 대기 메시지 {count}개를 만들까요?", + "repeatIntentConfirm": "{count}개 대기열에 추가", + "repeatIntentCancel": "취소" }, "welcomeInputPanel": { "agentsSettingsPath": "설정 > 에이전트", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 15941ece7..a0088b2d7 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2148,7 +2148,11 @@ "saveEdit": "Salvar", "cancelEdit": "Cancelar edição", "editItem": "Editar", - "deleteItem": "Remover" + "deleteItem": "Remover", + "repeatIntentTitle": "Colocar mensagens repetidas na fila?", + "repeatIntentDescription": "Criar {count} mensagens pendentes com: \"{text}\"?", + "repeatIntentConfirm": "Enfileirar {count} mensagens", + "repeatIntentCancel": "Cancelar" }, "welcomeInputPanel": { "agentsSettingsPath": "Configurações > Agentes", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 656c04865..0e9a3733a 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2148,7 +2148,11 @@ "saveEdit": "保存", "cancelEdit": "取消编辑", "editItem": "编辑", - "deleteItem": "删除" + "deleteItem": "删除", + "repeatIntentTitle": "生成重复待发送消息?", + "repeatIntentDescription": "是否生成 {count} 条待发送消息:「{text}」", + "repeatIntentConfirm": "生成 {count} 条待发送", + "repeatIntentCancel": "取消" }, "welcomeInputPanel": { "agentsSettingsPath": "设置 > Agents", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 34a7d3d2b..5abe22cc5 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2148,7 +2148,11 @@ "saveEdit": "儲存", "cancelEdit": "取消編輯", "editItem": "編輯", - "deleteItem": "刪除" + "deleteItem": "刪除", + "repeatIntentTitle": "產生重複待傳送訊息?", + "repeatIntentDescription": "是否產生 {count} 則待傳送訊息:「{text}」", + "repeatIntentConfirm": "產生 {count} 則待傳送", + "repeatIntentCancel": "取消" }, "welcomeInputPanel": { "agentsSettingsPath": "設定 > Agents", diff --git a/src/lib/repeat-intent.test.ts b/src/lib/repeat-intent.test.ts new file mode 100644 index 000000000..25e795a59 --- /dev/null +++ b/src/lib/repeat-intent.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest" +import { + MAX_REPEAT_COUNT, + MIN_REPEAT_COUNT, + applyRepeatBaseText, + parseRepeatIntent, +} from "./repeat-intent" +import type { PromptDraft } from "@/lib/types" + +describe("parseRepeatIntent", () => { + it.each([ + ["继续X10", "继续", 10], + ["继续x10", "继续", 10], + ["继续 X10", "继续", 10], + ["继续 x 10", "继续", 10], + ["continue X 3", "continue", 3], + ["请继续修复x10", "请继续修复", 10], + ["fix this\nplease x2", "fix this\nplease", 2], + ["继续x2 ", "继续", 2], + ["继续x50", "继续", 50], + ] as const)("parses %j", (input, baseText, count) => { + expect(parseRepeatIntent(input)).toEqual({ baseText, count }) + }) + + it.each([ + ["继续x1"], + ["继续x51"], + ["x10"], + ["继续X10 请"], + ["继续X10a"], + ["继续*10"], + [""], + [" "], + ["继续"], + ] as const)("rejects %j", (input) => { + expect(parseRepeatIntent(input)).toBeNull() + }) + + it("exposes bounds constants", () => { + expect(MIN_REPEAT_COUNT).toBe(2) + expect(MAX_REPEAT_COUNT).toBe(50) + }) +}) + +describe("applyRepeatBaseText", () => { + it("rewrites displayText and trailing text block only", () => { + const draft: PromptDraft = { + displayText: "继续x10", + blocks: [ + { type: "text", text: "继续x10" }, + { + type: "image", + data: "abc", + mime_type: "image/png", + }, + ], + } + expect(applyRepeatBaseText(draft, "继续")).toEqual({ + displayText: "继续", + blocks: [ + { type: "text", text: "继续" }, + { + type: "image", + data: "abc", + mime_type: "image/png", + }, + ], + }) + }) +}) diff --git a/src/lib/repeat-intent.ts b/src/lib/repeat-intent.ts new file mode 100644 index 000000000..a6e76a7ad --- /dev/null +++ b/src/lib/repeat-intent.ts @@ -0,0 +1,53 @@ +import type { PromptDraft, PromptInputBlock } from "@/lib/types" + +export const MIN_REPEAT_COUNT = 2 +export const MAX_REPEAT_COUNT = 50 + +export interface RepeatIntent { + baseText: string + count: number +} + +// Trailing multiplier: base + optional spaces + x/X + optional spaces + digits. +// DotAll so baseText may include newlines. Non-greedy base so the last xN is trailing. +const REPEAT_INTENT_RE = + /^(?[\s\S]+?)[ \t]*[xX][ \t]*(?\d+)[ \t]*$/ + +export function parseRepeatIntent(text: string): RepeatIntent | null { + const match = REPEAT_INTENT_RE.exec(text) + if (!match?.groups) return null + + const baseText = match.groups.base.replace(/[ \t]+$/u, "") + if (!baseText) return null + + const count = Number.parseInt(match.groups.count, 10) + if (!Number.isFinite(count)) return null + if (count < MIN_REPEAT_COUNT || count > MAX_REPEAT_COUNT) return null + + return { baseText, count } +} + +function rewriteTrailingTextBlock( + blocks: PromptInputBlock[], + baseText: string +): PromptInputBlock[] { + const next = blocks.map((block) => ({ ...block })) + for (let i = next.length - 1; i >= 0; i -= 1) { + if (next[i].type === "text") { + next[i] = { type: "text", text: baseText } + return next + } + } + if (baseText) return [{ type: "text", text: baseText }, ...next] + return next +} + +export function applyRepeatBaseText( + draft: PromptDraft, + baseText: string +): PromptDraft { + return { + displayText: baseText, + blocks: rewriteTrailingTextBlock(draft.blocks, baseText), + } +}