From fa7d88bee697dfab363ced82992a5163b1925ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 12 Aug 2026 20:29:18 +0100 Subject: [PATCH] fix(plugins): make the composer capability usable end to end (#2575) Co-authored-by: Kandev Agent --- .../fixture-package/ui/bundle.js | 124 ++++++- .../manifest/keybinding_editor_test.go | 106 ++++++ .../internal/plugins/manifest/manifest.go | 7 + .../internal/plugins/manifest/validate.go | 19 ++ .../task-create-dialog-selectors.test.tsx | 44 +++ .../task-create-dialog-selectors.tsx | 93 ++--- .../components/task/chat/chat-input-body.tsx | 74 ++-- .../chat/chat-input-toolbar-composer.test.tsx | 205 +++++++++++ .../task/chat/chat-input-toolbar-desktop.tsx | 2 + .../task/chat/chat-input-toolbar.tsx | 2 + .../components/task/chat/use-tiptap-editor.ts | 18 + .../tests/plugins/composer-actions.spec.ts | 320 ++++++++++++++++++ .../plugins/mobile-composer-actions.spec.ts | 213 ++++++++++++ apps/web/hooks/use-plugin-shortcuts.test.ts | 112 ++++++ apps/web/hooks/use-plugin-shortcuts.ts | 42 ++- .../lib/plugins/composer-capability.test.tsx | 162 +++++++++ apps/web/lib/plugins/composer-capability.ts | 105 +++++- apps/web/lib/types/plugins.ts | 6 + .../plugin-voice-extraction-host/plan.md | 4 +- .../task-06-docs-e2e-verification.md | 23 +- docs/plans/plugins/PLUGIN-API.md | 12 +- docs/public/plugins-authoring.md | 2 +- docs/public/plugins-manifest.md | 2 + docs/specs/plugins/voice-extraction-host.md | 9 +- 24 files changed, 1623 insertions(+), 83 deletions(-) create mode 100644 apps/backend/internal/plugins/manifest/keybinding_editor_test.go create mode 100644 apps/web/components/task/chat/chat-input-toolbar-composer.test.tsx create mode 100644 apps/web/e2e/tests/plugins/composer-actions.spec.ts create mode 100644 apps/web/e2e/tests/plugins/mobile-composer-actions.spec.ts create mode 100644 apps/web/lib/plugins/composer-capability.test.tsx diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js index 3f3b592ded..6ba0b677b7 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -22,7 +22,10 @@ * (get on mount, debounced set, subscribe to pick up a write from another * tab/surface), a task-card-indicators slot component, a task-card-tags slot * component, and a registerTaskMenuAction under the kanban card's "edit" - * group. + * group. It also registers one composer action on all three composer slots + * (chat-input-actions, task-create-input-actions, new-session-input-actions), + * which is how the e2e suite exercises PluginComposerCapability against real + * native composers rather than a mock. * * The task-created counter lives in module scope (not component state) with * a tiny listener set, so it survives across route navigations (the page @@ -247,6 +250,122 @@ ); } + + // ── Composer action ──────────────────────────────────────────────── + // + // Drives the PluginComposerCapability the host passes to + // chat-input-actions, task-create-input-actions and + // new-session-input-actions. This is what proves the contract end to + // end in a browser: a mock that calls the host's submit callback + // directly would prove nothing about the native path. + // + // "Capture" then "insert captured" deliberately splits the flow across + // renders, because that is the shape a real voice plugin has — the + // capability is grabbed when recording starts and used seconds later, + // after the host has re-rendered the composer many times. + // Deliberately tiny: the fixture needs five affordances where a real + // plugin contributes one icon button, and five full-size buttons + // overflow a phone toolbar and land underneath the native controls. + var COMPOSER_BUTTON_STYLE = { + fontSize: "9px", + lineHeight: "1", + padding: "2px 3px", + minWidth: "16px", + }; + + function ComposerAction(props) { + var slotProps = props.slotProps || {}; + var composer = slotProps.composer; + var statusState = React.useState(""); + var status = statusState[0]; + var setStatus = statusState[1]; + var capturedRef = React.useRef(null); + + function record(result) { + setStatus(result && result.status ? result.status : String(result)); + } + + return jsx( + "span", + { + "data-testid": "e2e-composer-action", + "data-surface": String(slotProps.surface), + "data-presentation": String(slotProps.presentation), + "data-task-id": String(slotProps.taskId || ""), + "data-session-id": String(slotProps.activeSessionId || ""), + "data-disabled": String(Boolean(slotProps.disabled)), + "data-submittable": String(Boolean(slotProps.submittable)), + "data-status": status, + }, + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-insert", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + record(composer.insertText("DICTATED")); + }, + }, + "insert", + ), + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-capture", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + capturedRef.current = composer; + setStatus("captured"); + }, + }, + "capture", + ), + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-insert-captured", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + record( + capturedRef.current + ? capturedRef.current.insertText("DICTATED") + : { status: "not-captured" }, + ); + }, + }, + "insert captured", + ), + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-submit", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + var target = capturedRef.current || composer; + target.submit().then(record); + }, + }, + "submit", + ), + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-focus", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + record(composer.focus()); + }, + }, + "focus", + ), + ); + } + registry.registerNavItem({ id: "e2e-hello", label: "Hello E2E", @@ -269,6 +388,9 @@ Component: NotesPanel, mobileEnabled: true, }); + registry.registerComponent("chat-input-actions", ComposerAction); + registry.registerComponent("task-create-input-actions", ComposerAction); + registry.registerComponent("new-session-input-actions", ComposerAction); registry.registerComponent("task-card-indicators", CardIndicator); registry.registerComponent("task-card-tags", CardTags); registry.registerTaskMenuAction({ diff --git a/apps/backend/internal/plugins/manifest/keybinding_editor_test.go b/apps/backend/internal/plugins/manifest/keybinding_editor_test.go new file mode 100644 index 0000000000..45253858b8 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/keybinding_editor_test.go @@ -0,0 +1,106 @@ +package manifest + +import ( + "strings" + "testing" +) + +// keybindingManifest returns a valid bundle-carrying manifest holding exactly +// the supplied keybindings. +func keybindingManifest(t *testing.T, keybindings ...UIKeybinding) *Manifest { + t.Helper() + m := validManifest(t) + m.UI.Bundle = "/ui/bundle.js" + m.UI.Keybindings = keybindings + return m +} + +func TestValidate_AllowInEditorAcceptsModifierCombos(t *testing.T) { + for _, combo := range []string{"mod+shift+m", "ctrl+m", "cmd+shift+v", "alt+k", "meta+j", "option+p"} { + m := keybindingManifest(t, UIKeybinding{ + ID: "toggle-dictation", + Default: combo, + Description: "Toggle dictation", + AllowInEditor: true, + }) + if err := m.Validate(); err != nil { + t.Errorf("Validate() with allow_in_editor and combo %q: unexpected error: %v", combo, err) + } + } +} + +func TestValidate_AllowInEditorRejectsCombosReachableByTyping(t *testing.T) { + for _, combo := range []string{"m", "shift+m", "f5"} { + m := keybindingManifest(t, UIKeybinding{ + ID: "toggle-dictation", + Default: combo, + Description: "Toggle dictation", + AllowInEditor: true, + }) + err := m.Validate() + if err == nil { + t.Errorf("Validate() with allow_in_editor and combo %q: expected an error, got nil", combo) + continue + } + if !strings.Contains(err.Error(), "allow_in_editor") { + t.Errorf("Validate() with combo %q: error should name allow_in_editor, got %v", combo, err) + } + } +} + +func TestValidate_ModifierlessComboStaysValidWithoutAllowInEditor(t *testing.T) { + m := keybindingManifest(t, UIKeybinding{ + ID: "toggle-dictation", + Default: "f5", + Description: "Toggle dictation", + }) + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } +} + +func TestValidate_AllowInEditorDoesNotMaskAnInvalidCombo(t *testing.T) { + m := keybindingManifest(t, UIKeybinding{ + ID: "toggle-dictation", + Default: "mod+nope", + Description: "Toggle dictation", + AllowInEditor: true, + }) + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected an error for an unknown key token, got nil") + } + if strings.Contains(err.Error(), "allow_in_editor") { + t.Fatalf("an unparseable combo should report the parse failure, not the allow_in_editor rule: %v", err) + } +} + +func TestParse_ReadsAllowInEditorFromYAML(t *testing.T) { + m, err := Parse([]byte(` +id: kandev-plugin-voice +api_version: 1 +version: "0.1.0" +ui: + bundle: "/ui/bundle.js" + keybindings: + - id: toggle-dictation + default: "mod+shift+m" + description: "Toggle dictation" + allow_in_editor: true + - id: open-settings + default: "mod+shift+p" + description: "Open settings" +`)) + if err != nil { + t.Fatalf("Parse() unexpected error: %v", err) + } + if len(m.UI.Keybindings) != 2 { + t.Fatalf("expected 2 keybindings, got %d", len(m.UI.Keybindings)) + } + if !m.UI.Keybindings[0].AllowInEditor { + t.Error("toggle-dictation should have allow_in_editor set") + } + if m.UI.Keybindings[1].AllowInEditor { + t.Error("open-settings should default allow_in_editor to false") + } +} diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index 23eb1b3ee5..a32415510c 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -197,6 +197,13 @@ type UIKeybinding struct { ID string `yaml:"id" json:"id"` Default string `yaml:"default" json:"default"` Description string `yaml:"description" json:"description"` + // AllowInEditor opts this one binding out of the dispatcher's + // skip-while-typing rule. By default a plugin keybinding does not fire + // when the keydown target is an input, textarea or contenteditable, so a + // plugin cannot shadow a character the user is trying to type. A binding + // whose whole purpose is to act on the focused composer (dictation, + // for instance) has to run there, and declares it here. + AllowInEditor bool `yaml:"allow_in_editor,omitempty" json:"allow_in_editor,omitempty"` } // Parse decodes a plugin manifest from YAML bytes. It does not validate the diff --git a/apps/backend/internal/plugins/manifest/validate.go b/apps/backend/internal/plugins/manifest/validate.go index c55b16b767..66e443b17b 100644 --- a/apps/backend/internal/plugins/manifest/validate.go +++ b/apps/backend/internal/plugins/manifest/validate.go @@ -423,11 +423,30 @@ func (m *Manifest) validateUIKeybindings() []error { } if err := parseKeybindingCombo(kb.Default); err != nil { errs = append(errs, fmt.Errorf("ui.keybindings[%q]: %w", kb.ID, err)) + continue + } + if kb.AllowInEditor && !comboHasNonShiftModifier(kb.Default) { + errs = append(errs, fmt.Errorf( + "ui.keybindings[%q]: allow_in_editor requires a ctrl/cmd/mod/alt modifier, otherwise the binding would swallow %q while the user is typing", + kb.ID, kb.Default)) } } return errs } +// comboHasNonShiftModifier reports whether an already-parsed combo holds a +// modifier that makes it unreachable by ordinary typing. Shift alone does +// not count: shift+a is just a capital A. +func comboHasNonShiftModifier(combo string) bool { + for _, raw := range strings.Split(combo, "+") { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "mod", "ctrl", "cmd", "meta", "alt", "option": + return true + } + } + return false +} + // validateKeybindingID checks a single keybinding id against the slug // pattern and records it in seen, reporting a duplicate-id error on repeat. func validateKeybindingID(id string, seen map[string]bool) []error { diff --git a/apps/web/components/task-create-dialog-selectors.test.tsx b/apps/web/components/task-create-dialog-selectors.test.tsx index 34d40ec7c0..ff40ba8d22 100644 --- a/apps/web/components/task-create-dialog-selectors.test.tsx +++ b/apps/web/components/task-create-dialog-selectors.test.tsx @@ -212,6 +212,50 @@ describe("TaskFormInputs plugin composer submission", () => { }); }); +describe("TaskFormInputs plugin composer chained calls", () => { + it("submits a transcript inserted in the same callback, before React re-renders", async () => { + // The shape a dictation plugin actually has: insert the transcript and + // submit it without yielding. The gate must read the synchronous value, + // not the render snapshot, or an empty composer stays "blocked" forever. + const onVoiceAutoSend = vi.fn(() => true); + const ref = createRef(); + render( + {}} + onKeyDown={() => {}} + descriptionValueRef={ref} + onVoiceAutoSend={onVoiceAutoSend} + />, + { wrapper: Wrapper }, + ); + + const composer = lastPluginSlotProps().composer; + let result: Awaited> | undefined; + await act(async () => { + composer.insertText("dictated prompt"); + result = await composer.submit(); + }); + + expect(result).toEqual({ status: "submitted" }); + expect(onVoiceAutoSend).toHaveBeenCalledTimes(1); + expect(ref.current?.getValue()).toBe("dictated prompt"); + }); + + it("keeps both transcripts when a plugin inserts twice in one callback", () => { + const { textarea } = renderTaskFormInputs(""); + + act(() => { + lastPluginSlotProps().composer.insertText("first"); + lastPluginSlotProps().composer.insertText("second"); + }); + + expect(textarea.value).toBe("first second"); + }); +}); + describe("TaskFormInputs voice-input wiring — at-cursor splice", () => { it("splices the transcript at the caret with a leading space after a word", () => { const { textarea } = renderTaskFormInputs("hello world"); diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 911efd839e..9cc754afe1 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -43,7 +43,11 @@ import { ApiError } from "@/lib/api/client"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; import { PluginSlot } from "@/components/plugins/plugin-slot"; -import { createPluginComposerCapability } from "@/lib/plugins/composer-capability"; +import { + composerIdentity, + composerInsertionText, + useStablePluginComposerCapability, +} from "@/lib/plugins/composer-capability"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; const CURSOR_POINTER_CLASS = "cursor-pointer"; @@ -671,22 +675,28 @@ function useDescriptionInput( const insertAtCursor = useCallback( (text: string) => { - const trimmed = text.trim(); - if (!trimmed) return; + // Read the synchronous ref, not the render snapshot: a plugin can + // insert twice (or insert then submit) inside one callback, before React + // has re-rendered with the first insertion. + const current = descriptionRef.current; const textarea = textareaRef.current; - const start = textarea?.selectionStart ?? description.length; - const end = textarea?.selectionEnd ?? description.length; - const charBefore = start > 0 ? description.charAt(start - 1) : ""; - const needsLeadingSpace = charBefore !== "" && !/\s/.test(charBefore); - const insert = needsLeadingSpace ? ` ${trimmed}` : trimmed; - const next = description.slice(0, start) + insert + description.slice(end); + // A caret we set but have not applied yet outranks the DOM's: after a + // programmatic insertion the textarea still reports the pre-insert + // selection until the layout effect below runs, so a second insertion in + // the same callback would splice at the old offset. + const pending = pendingCursorRef.current; + const start = pending ?? textarea?.selectionStart ?? current.length; + const end = pending ?? textarea?.selectionEnd ?? current.length; + const insert = composerInsertionText(text, start > 0 ? current.charAt(start - 1) : ""); + if (!insert) return; + const next = current.slice(0, start) + insert + current.slice(end); pendingCursorRef.current = start + insert.length; setDescriptionValue(next); }, - [description, setDescriptionValue], + [setDescriptionValue], ); - return { description, textareaRef, setDescriptionValue, insertAtCursor }; + return { description, descriptionRef, textareaRef, setDescriptionValue, insertAtCursor }; } type FormInputsToolbarProps = { @@ -778,43 +788,46 @@ function useCreationComposerPluginActions(args: { taskId: string | null; disabled: boolean; description: string; + descriptionRef: React.RefObject; textareaRef: React.RefObject; insertAtCursor: (text: string) => void; submit?: () => boolean | Promise; }) { const { isMobile } = useResponsiveBreakpoint(); - const handle = useMemo( - () => - createPluginComposerCapability({ - insertText: (text) => { - args.insertAtCursor(text); - return true; - }, - focus: () => { - if (!args.textareaRef.current) return false; - args.textareaRef.current.focus(); - return true; - }, - submit: async () => { - if (args.disabled || !args.description.trim() || !args.submit) return false; - return await args.submit(); - }, - }), - [args], + const surface = args.isSessionMode ? "new-session" : "task-create"; + const composer = useStablePluginComposerCapability( + { + insertText: (text) => { + args.insertAtCursor(text); + return true; + }, + focus: () => { + if (!args.textareaRef.current) return false; + args.textareaRef.current.focus(); + return true; + }, + // Gate on the synchronous ref for the same reason the chat composer + // reads its editor: insert-then-submit in one callback happens before + // React re-renders with the new description. + submit: async () => { + if (args.disabled || !args.descriptionRef.current.trim() || !args.submit) return false; + return await args.submit(); + }, + }, + composerIdentity(surface, args.taskId, null), ); - useEffect(() => () => handle.revoke(), [handle]); return ( 0, - composer: handle.api, + composer, }} /> ); @@ -923,13 +936,14 @@ export const TaskFormInputs = memo(function TaskFormInputs({ () => toContextItems(attachments, handleRemoveAttachment, handleRetryAttachment), [attachments, handleRemoveAttachment, handleRetryAttachment], ); - const { description, textareaRef, setDescriptionValue, insertAtCursor } = useDescriptionInput( - initialDescription, - autoFocus, - descriptionValueRef, - onDescriptionChange, - attachments, - ); + const { description, descriptionRef, textareaRef, setDescriptionValue, insertAtCursor } = + useDescriptionInput( + initialDescription, + autoFocus, + descriptionValueRef, + onDescriptionChange, + attachments, + ); const mention = useTaskCreatePromptMention({ textareaRef, value: description, @@ -946,6 +960,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({ taskId, disabled: Boolean(disabled), description, + descriptionRef, textareaRef, insertAtCursor, submit: onVoiceAutoSend, diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index 764aba4ba4..47bfbd3c82 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -13,7 +13,11 @@ import type { ContextItem } from "@/lib/types/context"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { ImagePasteIssue } from "./clipboard-attachments"; import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; -import { createPluginComposerCapability } from "@/lib/plugins/composer-capability"; +import { + composerIdentity, + composerInsertionText, + useStablePluginComposerCapability, +} from "@/lib/plugins/composer-capability"; import type { PluginComposerCapability } from "@/lib/plugins/types"; export type ChatInputEditorAreaProps = { @@ -135,35 +139,45 @@ function useChatPluginComposer(p: { inputRef: ChatInputEditorAreaProps["inputRef"]; submitDisabled: boolean; isEnhancingPrompt: boolean; + hasContent: boolean; + identity: string; onSubmit: () => void; }): PluginComposerCapability { - const handle = useMemo( - () => - createPluginComposerCapability({ - insertText: (text) => { - const editor = p.inputRef.current; - if (!editor) return false; - const from = editor.getSelectionStart(); - const prefix = from > 0 && !/\s/.test(editor.getValue().charAt(from - 1)) ? " " : ""; - editor.insertText(prefix + text.trim(), from, editor.getSelectionEnd()); - editor.focus(); - return true; - }, - focus: () => { - if (!p.inputRef.current) return false; - p.inputRef.current.focus(); - return true; - }, - submit: async () => { - if (p.isEnhancingPrompt || p.submitDisabled) return false; - p.onSubmit(); - return true; - }, - }), - [p.inputRef, p.isEnhancingPrompt, p.onSubmit, p.submitDisabled], + return useStablePluginComposerCapability( + { + insertText: (text) => { + const editor = p.inputRef.current; + if (!editor) return false; + const insertion = composerInsertionText(text, editor.getCharBefore()); + if (!insertion) return false; + editor.insertText(insertion, editor.getSelectionStart(), editor.getSelectionEnd()); + editor.focus(); + return true; + }, + focus: () => { + if (!p.inputRef.current) return false; + p.inputRef.current.focus(); + return true; + }, + // Revalidated at call time against the same gate the slot advertises as + // `submittable`, so a capability never submits an empty draft it just + // told the plugin was not submittable. + // + // The draft is read from the editor, not from `hasContent`. A plugin may + // insert a transcript and submit in one callback, and React has not + // re-rendered by then, so the render snapshot still says "empty" while + // the editor already holds the text. `submitDraft` reads its own + // synchronous `valueRef` for the same reason. + submit: async () => { + if (p.isEnhancingPrompt || p.submitDisabled) return false; + const liveDraft = p.inputRef.current?.getValue() ?? ""; + if (liveDraft.trim().length === 0 && !p.hasContent) return false; + p.onSubmit(); + return true; + }, + }, + p.identity, ); - useEffect(() => () => handle.revoke(), [handle]); - return handle.api; } export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { @@ -190,6 +204,10 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { inputRef, submitDisabled: p.submitDisabled, isEnhancingPrompt: Boolean(isEnhancingPrompt), + hasContent, + // The container is not remounted when the user switches task or session, + // so this is what tells a captured plugin handle it has been superseded. + identity: composerIdentity(taskId ? "task-chat" : "quick-chat", taskId, sessionId), onSubmit: handleSubmitWithReset, }); const submitDisabledReason = p.hasPendingAttachmentUploads diff --git a/apps/web/components/task/chat/chat-input-toolbar-composer.test.tsx b/apps/web/components/task/chat/chat-input-toolbar-composer.test.tsx new file mode 100644 index 0000000000..5785e5bb51 --- /dev/null +++ b/apps/web/components/task/chat/chat-input-toolbar-composer.test.tsx @@ -0,0 +1,205 @@ +/** + * The composer capability has to survive the whole prop chain + * `ChatInputToolbar -> Desktop/MobileChatInputToolbar -> DesktopRightSection + * -> ChatInputPluginActions`. Every hop in that chain forwards an explicit + * prop list, and two of them silently dropped `composerCapability` — the slot + * then fell back to the always-`unavailable` stub, so a plugin action + * rendered fine and did nothing. + * + * These tests render the real top-level `ChatInputToolbar` at both + * presentations and assert on what the slot actually received, which is the + * only level at which a dropped hop is visible. `chat-input-toolbar.test.tsx` + * mocks the slot away, and the sub-toolbar tests render past the missing hop. + */ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { StateProvider } from "@/components/state-provider"; +import type { PluginComposerCapability } from "@/lib/plugins/types"; +import type { ChatInputActionsSlotProps } from "./chat-input-plugin-actions"; + +const responsiveMock = vi.hoisted(() => ({ + breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop", +})); + +const slotSpy = vi.hoisted(() => ({ + received: [] as ChatInputActionsSlotProps[], +})); + +afterEach(() => { + cleanup(); +}); + +beforeEach(() => { + responsiveMock.breakpoint = "desktop"; + slotSpy.received = []; +}); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ + breakpoint: responsiveMock.breakpoint, + isMobile: responsiveMock.breakpoint === "mobile", + isTablet: responsiveMock.breakpoint === "tablet", + isDesktop: + responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop", + isCompactDesktop: responsiveMock.breakpoint === "compactDesktop", + isFullDesktop: responsiveMock.breakpoint === "desktop", + isFinePointer: true, + usesDesktopWorkbench: + responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop", + }), +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("@/components/keyboard-shortcut-tooltip", () => ({ + KeyboardShortcutTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("@/components/task/model-selector", () => ({ + ModelSelector: () => , +})); + +vi.mock("@/components/task/mode-selector", () => ({ + ModeSelector: () => , +})); + +vi.mock("@/components/task/sessions-dropdown", () => ({ + SessionsDropdown: () => , +})); + +vi.mock("@/components/task/chat/token-usage-display", () => ({ + TokenUsageDisplay: () => , +})); + +vi.mock("@/components/enhance-prompt-button", () => ({ + EnhancePromptButton: () => , +})); + +vi.mock("./context-popover", () => ({ + ContextPopover: ({ trigger }: { trigger: React.ReactNode }) => <>{trigger}, +})); + +vi.mock("./implement-plan-button", () => ({ + ImplementPlanButton: () => , +})); + +vi.mock("./reset-context-button", () => ({ + ResetContextButton: () => , +})); + +vi.mock("./voice-input-button", () => ({ + VoiceInputButton: () => , +})); + +// Records what the slot was handed instead of rendering plugin components. +vi.mock("./chat-input-plugin-actions", () => ({ + ChatInputPluginActions: (props: Record) => { + slotSpy.received.push(props as unknown as ChatInputActionsSlotProps); + return null; + }, +})); + +import { ChatInputToolbar } from "./chat-input-toolbar"; +import type { ChatInputToolbarProps } from "./chat-input-toolbar"; + +function makeCapability(): PluginComposerCapability { + return { + insertText: () => ({ status: "inserted" }), + focus: () => ({ status: "focused" }), + submit: async () => ({ status: "submitted" }), + }; +} + +function renderToolbar(overrides: Partial = {}) { + return render( + + {}} + sessionId="s1" + taskId="t1" + taskDescription="" + isAgentBusy={false} + hasContent + isDisabled={false} + isSending={false} + onCancel={() => {}} + onSubmit={() => {}} + hidePlanMode + onAttachFiles={() => {}} + {...overrides} + /> + , + ); +} + +function lastSlotProps(): ChatInputActionsSlotProps { + const last = slotSpy.received.at(-1); + expect(last, "the plugin slot should have rendered").toBeDefined(); + return last!; +} + +describe("ChatInputToolbar forwards the composer capability to the plugin slot", () => { + it("reaches the slot on desktop", () => { + const composer = makeCapability(); + renderToolbar({ composerCapability: composer }); + + expect(lastSlotProps().composer).toBe(composer); + }); + + it("reaches the slot on mobile", () => { + responsiveMock.breakpoint = "mobile"; + const composer = makeCapability(); + renderToolbar({ composerCapability: composer }); + + expect(lastSlotProps().composer).toBe(composer); + }); + + it("reaches the slot on a tablet, which uses the compact toolbar", () => { + responsiveMock.breakpoint = "tablet"; + const composer = makeCapability(); + renderToolbar({ composerCapability: composer }); + + expect(lastSlotProps().composer).toBe(composer); + }); + + it("carries the surface through so Quick Chat is distinguishable from task chat", () => { + renderToolbar({ composerCapability: makeCapability(), composerSurface: "quick-chat" }); + + expect(lastSlotProps().surface).toBe("quick-chat"); + }); + + it("reports the live disabled and submittable gates rather than a snapshot", () => { + const composer = makeCapability(); + const { rerender } = renderToolbar({ composerCapability: composer, hasContent: false }); + expect(lastSlotProps().submittable).toBe(false); + + rerender( + + {}} + sessionId="s1" + taskId="t1" + taskDescription="" + isAgentBusy={false} + hasContent + isDisabled={false} + isSending={false} + onCancel={() => {}} + onSubmit={() => {}} + hidePlanMode + onAttachFiles={() => {}} + composerCapability={composer} + /> + , + ); + + expect(lastSlotProps().submittable).toBe(true); + }); +}); diff --git a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx index f9dee9936a..e5ed030357 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx @@ -293,6 +293,8 @@ export function DesktopChatInputToolbar(props: DesktopToolbarProps) { submitShortcut={props.submitShortcut} onVoiceTranscript={props.onVoiceTranscript} onVoiceAutoSend={props.onVoiceAutoSend} + composerCapability={props.composerCapability} + composerSurface={props.composerSurface} /> ); diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index 73f31e88d0..8444e989bf 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -185,6 +185,8 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp submitShortcut={submitShortcut} onVoiceTranscript={props.onVoiceTranscript} onVoiceAutoSend={props.onVoiceAutoSend} + composerCapability={props.composerCapability} + composerSurface={props.composerSurface} /> ); } diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index fe31bad663..7435f423d1 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -42,6 +42,15 @@ export type TipTapInputHandle = { getSelectionStart: () => number; getSelectionEnd: () => number; getValue: () => string; + /** + * The single character immediately before the current selection, or "" at + * the very start. Read from the ProseMirror document rather than derived + * from `getValue()`: selection offsets are doc positions (1-based, counting + * node boundaries) while `getValue()` returns markdown, so indexing one + * with the other is off by at least one and drifts further with mentions + * and code blocks. + */ + getCharBefore: () => string; setValue: (value: string) => void; clear: () => void; getTextareaElement: () => HTMLElement | null; @@ -500,6 +509,15 @@ function useEditorImperativeHandle( getSelectionStart: () => editor?.state.selection.from ?? 0, getSelectionEnd: () => editor?.state.selection.to ?? 0, getValue: () => (editor ? getMarkdownText(editor) : ""), + getCharBefore: () => { + if (!editor) return ""; + const { from } = editor.state.selection; + // Position 1 is the first character of the first block, so nothing + // precedes it. `\n` for block/leaf gaps keeps a line start counting + // as whitespace, which is what the spacing rule wants. + if (from <= 1) return ""; + return editor.state.doc.textBetween(from - 1, from, "\n", "\n"); + }, setValue: (v: string) => { if (!editor) return; isSyncingRef.current = true; diff --git a/apps/web/e2e/tests/plugins/composer-actions.spec.ts b/apps/web/e2e/tests/plugins/composer-actions.spec.ts new file mode 100644 index 0000000000..dbbbfb6ffe --- /dev/null +++ b/apps/web/e2e/tests/plugins/composer-actions.spec.ts @@ -0,0 +1,320 @@ +/** + * E2E: `PluginComposerCapability` against the real native composers + * (docs/specs/plugins/voice-extraction-host.md). + * + * These are the prerequisites the extracted Voice Mode plugin stands on, so + * everything here goes through a genuinely registered plugin slot and then + * observes what the *native* path produced — a created task, a sent message, + * a launched session. A test that called the host's submit callback directly + * would prove nothing about native validation, steering, or draft ownership. + * + * The fixture plugin registers one composer action on all three slots; see + * apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js. It exposes + * insert / capture / insert-captured / submit / focus buttons plus the live + * slot props as data attributes. + */ +import { expect, test } from "../../fixtures/test-base"; +import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture"; +import { SessionPage } from "../../pages/session-page"; +import { KanbanPage } from "../../pages/kanban-page"; +import type { ApiClient } from "../../helpers/api-client"; +import type { Locator, Page } from "@playwright/test"; + +const DICTATED = "DICTATED"; +const CREATED_TITLE = "Composer created task"; + +async function uninstallViaApi(apiClient: ApiClient): Promise { + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); +} + +/** The fixture's action inside a specific composer (dialog, chat, ...). */ +function action(scope: Page | Locator): Locator { + return scope.getByTestId("e2e-composer-action").first(); +} + +/** + * Places the caret in the middle of an already-typed draft, so an insertion + * that merely appends is distinguishable from one that honours the selection. + * + * Walks left from the end rather than using Control+Home: `fill` leaves the + * caret at the end on every platform, while Control+Home is a no-op under + * Android emulation and silently leaves the caret where it was, which makes + * the assertion vacuous instead of failing. + */ +async function caretBackFromEnd(target: Locator, steps: number): Promise { + await target.click(); + for (let i = 0; i < steps; i++) await target.press("ArrowLeft"); +} + +test.describe("Plugins — composer capability", () => { + test.afterEach(async ({ apiClient }) => { + await uninstallViaApi(apiClient); + }); + + test("task chat: inserts at the selection and submits through the native handler", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + await installFixturePlugin(testPage); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Composer capability task chat", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const editor = await session.composerReady(); + + const composerAction = action(session.activeChat()); + await expect(composerAction).toBeVisible(); + await expect(composerAction).toHaveAttribute("data-surface", "task-chat"); + await expect(composerAction).toHaveAttribute("data-presentation", "desktop"); + await expect(composerAction).toHaveAttribute("data-task-id", task.id); + + // An empty composer is not submittable; the props are live, not a + // snapshot taken when the slot first mounted. + await expect(composerAction).toHaveAttribute("data-submittable", "false"); + + await editor.fill("head tail"); + await expect(composerAction).toHaveAttribute("data-submittable", "true"); + + // Caret between "head" and " tail" — a plain append would land at the end. + await caretBackFromEnd(editor, " tail".length); + await composerAction.getByTestId("e2e-composer-insert").click(); + await expect(composerAction).toHaveAttribute("data-status", "inserted"); + await expect(editor).toHaveText(`head ${DICTATED} tail`); + + await composerAction.getByTestId("e2e-composer-submit").click(); + await expect(composerAction).toHaveAttribute("data-status", "submitted"); + + // The native submit path is what actually sent it: the message exists on + // the session, and the composer was cleared by the host, not by us. + await expect(editor).toHaveText(""); + await session.expectChatResponseVisible(`head ${DICTATED} tail`); + }); + + test("task chat: a capability captured before an edit still inserts and submits", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + await installFixturePlugin(testPage); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Composer capability stability", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const editor = await session.composerReady(); + const composerAction = action(session.activeChat()); + + // This is the voice plugin's actual shape: grab the capability when + // recording starts, then use it after the user has typed (and the host + // has re-rendered the composer many times, flipping `submittable`). + await composerAction.getByTestId("e2e-composer-capture").click(); + await expect(composerAction).toHaveAttribute("data-status", "captured"); + + await editor.fill("typed while recording"); + await expect(composerAction).toHaveAttribute("data-submittable", "true"); + + await composerAction.getByTestId("e2e-composer-insert-captured").click(); + await expect(composerAction).toHaveAttribute("data-status", "inserted"); + await expect(editor).toHaveText(`typed while recording ${DICTATED}`); + + await composerAction.getByTestId("e2e-composer-submit").click(); + await expect(composerAction).toHaveAttribute("data-status", "submitted"); + await session.expectChatResponseVisible(`typed while recording ${DICTATED}`); + }); + + test("task chat: submitting an empty composer is blocked and keeps the draft", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + await installFixturePlugin(testPage); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Composer capability blocked submit", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const editor = await session.composerReady(); + const composerAction = action(session.activeChat()); + + await expect(composerAction).toHaveAttribute("data-submittable", "false"); + await composerAction.getByTestId("e2e-composer-submit").click(); + await expect(composerAction).toHaveAttribute("data-status", "blocked"); + await expect(editor).toHaveText(""); + }); + + test("task creation: inserts at the textarea selection and the native form creates the task", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + await installFixturePlugin(testPage); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + const description = dialog.getByTestId("task-description-input"); + await expect(description).toBeVisible(); + + const composerAction = action(dialog); + await expect(composerAction).toBeVisible(); + await expect(composerAction).toHaveAttribute("data-surface", "task-create"); + await expect(composerAction).toHaveAttribute("data-submittable", "false"); + + await description.fill("head tail"); + await expect(composerAction).toHaveAttribute("data-submittable", "true"); + await caretBackFromEnd(description, " tail".length); + + await composerAction.getByTestId("e2e-composer-insert").click(); + await expect(composerAction).toHaveAttribute("data-status", "inserted"); + await expect(description).toHaveValue(`head ${DICTATED} tail`); + + // The title still comes from the native field: the capability owns the + // prompt, not the whole form. + await dialog.getByTestId("task-title-input").fill(CREATED_TITLE); + await expect(dialog.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 }); + + await composerAction.getByTestId("e2e-composer-submit").click(); + await expect(composerAction).toHaveAttribute("data-status", "submitted"); + + // The native creation handler ran, not a plugin-built request: the app + // navigated to a real task and the dictated prompt reached the agent. + await expect(testPage).toHaveURL(/\/t\//, { timeout: 30_000 }); + await expect + .poll( + async () => { + const { tasks } = await apiClient.listTasks(seedData.workspaceId); + return tasks.some((t) => t.title === CREATED_TITLE); + }, + { timeout: 30_000 }, + ) + .toBe(true); + }); + + test("new session: inserts at the selection and the native handler launches the session", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + await installFixturePlugin(testPage); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Composer capability new session", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const before = (await apiClient.listTaskSessions(task.id)).sessions.length; + + await session.openNewSessionDialog(); + const dialog = session.newSessionDialog(); + await expect(dialog).toBeVisible(); + + const description = dialog.getByTestId("task-description-input"); + await expect(description).toBeVisible(); + + const composerAction = action(dialog); + await expect(composerAction).toBeVisible(); + await expect(composerAction).toHaveAttribute("data-surface", "new-session"); + + await description.fill("head tail"); + await caretBackFromEnd(description, " tail".length); + await composerAction.getByTestId("e2e-composer-insert").click(); + await expect(description).toHaveValue(`head ${DICTATED} tail`); + + await composerAction.getByTestId("e2e-composer-submit").click(); + await expect(composerAction).toHaveAttribute("data-status", "submitted"); + + await expect + .poll(async () => (await apiClient.listTaskSessions(task.id)).sessions.length, { + timeout: 45_000, + }) + .toBeGreaterThan(before); + }); + + test("an uninstalled plugin leaves no composer action behind", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + await installFixturePlugin(testPage); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Composer capability cleanup", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.composerReady(); + await expect(action(session.activeChat())).toBeVisible(); + + await uninstallViaApi(apiClient); + await testPage.reload(); + await session.waitForLoad(); + await session.composerReady(); + + await expect(testPage.getByTestId("e2e-composer-action")).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/plugins/mobile-composer-actions.spec.ts b/apps/web/e2e/tests/plugins/mobile-composer-actions.spec.ts new file mode 100644 index 0000000000..7af5b7d515 --- /dev/null +++ b/apps/web/e2e/tests/plugins/mobile-composer-actions.spec.ts @@ -0,0 +1,213 @@ +/** + * Pixel 5 coverage for `PluginComposerCapability` + * (docs/specs/plugins/voice-extraction-host.md, "Mobile Design Contract"). + * + * Capability parity, not layout parity: the same action, the same insertion + * and the same native submit have to work by touch, from the shipped mobile + * composer toolbar and the shipped full-height dialogs. There is no separate + * plugin drawer to open. + * + * Filename matches /mobile-.*\.spec\.ts/ so the `mobile-chrome` project picks + * it up. + */ +import { expect, test } from "../../fixtures/test-base"; +import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture"; +import { SessionPage } from "../../pages/session-page"; +import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; +import type { ApiClient } from "../../helpers/api-client"; +import type { Locator, Page } from "@playwright/test"; + +const DICTATED = "DICTATED"; + +async function uninstallViaApi(apiClient: ApiClient): Promise { + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); +} + +function action(scope: Page | Locator): Locator { + return scope.getByTestId("e2e-composer-action").first(); +} + +/** + * What the host owes a plugin action on a phone: a place in the shipped + * composer toolbar whose hit box is fully on screen and not covered by a + * native control. The button's own size is the plugin's business — the Voice + * plugin, for instance, grows its control to 40px on a coarse pointer — and + * this fixture keeps its buttons deliberately tiny so five of them fit where + * a real plugin puts one. + */ +async function expectTouchReachable(page: Page, button: Locator): Promise { + await expect(button).toBeVisible(); + const box = await button.boundingBox(); + expect(box, "the action must have a hit box").not.toBeNull(); + expect(box!.width).toBeGreaterThan(0); + expect(box!.height).toBeGreaterThan(0); + + const viewport = page.viewportSize(); + expect(viewport).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width); + + // Nothing native may sit on top of it: a tap has to land on the action. + const hit = await page.evaluate( + ({ x, y }) => { + const el = document.elementFromPoint(x, y); + return el?.closest("[data-testid='e2e-composer-action']") !== null; + }, + { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }, + ); + expect(hit, "a native control is intercepting taps on the plugin action").toBe(true); +} + +/** + * Walks left from the end rather than using Control+Home, which is a no-op + * under Android emulation and silently leaves the caret at the end. The + * insertion then appends and the selection-honouring assertion passes for the + * wrong reason. + */ +async function caretBackFromEnd(target: Locator, steps: number): Promise { + await target.click(); + for (let i = 0; i < steps; i++) await target.press("ArrowLeft"); +} + +async function expectNoHorizontalOverflow(page: Page): Promise { + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe( + await page.evaluate(() => document.documentElement.clientWidth), + ); +} + +test.describe("Mobile plugin composer actions", () => { + test.describe.configure({ retries: 1, timeout: 120_000 }); + + test.afterEach(async ({ apiClient }) => { + await uninstallViaApi(apiClient); + }); + + test("task chat: the action is touch-reachable and native submit sends the dictated draft", async ({ + testPage, + apiClient, + seedData, + }) => { + await installFixturePlugin(testPage); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile composer capability", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const editor = await session.composerReady(); + + const composerAction = action(session.activeChat()); + await expect(composerAction).toHaveAttribute("data-surface", "task-chat"); + await expect(composerAction).toHaveAttribute("data-presentation", "mobile"); + await expectTouchReachable(testPage, composerAction.getByTestId("e2e-composer-insert")); + + await editor.fill("head tail"); + await caretBackFromEnd(editor, " tail".length); + + await composerAction.getByTestId("e2e-composer-insert").tap(); + await expect(editor).toHaveText(`head ${DICTATED} tail`); + + await composerAction.getByTestId("e2e-composer-submit").tap(); + await expect(composerAction).toHaveAttribute("data-status", "submitted"); + await session.expectChatResponseVisible(`head ${DICTATED} tail`); + + // The composer toolbar still owns its own row: adding a plugin action + // must not push the page sideways. + await expectNoHorizontalOverflow(testPage); + }); + + test("task creation: the action rides the prompt toolbar in the responsive dialog", async ({ + testPage, + }) => { + await installFixturePlugin(testPage); + + // The shipped mobile entry point: the global rail is hidden on a phone, + // so task creation starts from the board's floating action button. + const kanban = new MobileKanbanPage(testPage); + await kanban.goto(); + await kanban.mobileFab.tap(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + const description = dialog.getByTestId("task-description-input"); + await expect(description).toBeVisible(); + + const composerAction = action(dialog); + await expect(composerAction).toHaveAttribute("data-surface", "task-create"); + await expect(composerAction).toHaveAttribute("data-presentation", "mobile"); + await expectTouchReachable(testPage, composerAction.getByTestId("e2e-composer-insert")); + + await description.fill("head tail"); + await caretBackFromEnd(description, " tail".length); + + await composerAction.getByTestId("e2e-composer-insert").tap(); + await expect(description).toHaveValue(`head ${DICTATED} tail`); + + // The dialog still owns its own scrolling and the page does not pan. + await expectNoHorizontalOverflow(testPage); + }); + + test("new session: the action is reachable inside the full-height dialog and launches a session", async ({ + testPage, + apiClient, + seedData, + }) => { + await installFixturePlugin(testPage); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile new session composer", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const before = (await apiClient.listTaskSessions(task.id)).sessions.length; + + await session.openMobileNewSessionDialog(); + const dialog = session.newSessionDialog(); + await expect(dialog).toBeVisible(); + + const description = dialog.getByTestId("task-description-input"); + await expect(description).toBeVisible(); + + const composerAction = action(dialog); + await expect(composerAction).toHaveAttribute("data-surface", "new-session"); + await expect(composerAction).toHaveAttribute("data-presentation", "mobile"); + await expectTouchReachable(testPage, composerAction.getByTestId("e2e-composer-insert")); + + await description.fill("head tail"); + await caretBackFromEnd(description, " tail".length); + + await composerAction.getByTestId("e2e-composer-insert").tap(); + await expect(description).toHaveValue(`head ${DICTATED} tail`); + + await composerAction.getByTestId("e2e-composer-submit").tap(); + await expect(composerAction).toHaveAttribute("data-status", "submitted"); + + await expect + .poll(async () => (await apiClient.listTaskSessions(task.id)).sessions.length, { + timeout: 60_000, + }) + .toBeGreaterThan(before); + await expectNoHorizontalOverflow(testPage); + }); +}); diff --git a/apps/web/hooks/use-plugin-shortcuts.test.ts b/apps/web/hooks/use-plugin-shortcuts.test.ts index 3d97431a98..417ebb457a 100644 --- a/apps/web/hooks/use-plugin-shortcuts.test.ts +++ b/apps/web/hooks/use-plugin-shortcuts.test.ts @@ -181,6 +181,118 @@ describe("usePluginShortcuts", () => { }); }); +describe("usePluginShortcuts editable-target opt-in", () => { + beforeEach(() => { + mockOverrides = {}; + mockItems = []; + }); + + afterEach(() => { + cleanup(); + pluginRegistry.unregisterPlugin(PLUGIN_ID); + }); + + it("dispatches inside an editable element when the binding declared allow_in_editor", () => { + mockItems = [ + makePlugin({ ui: { keybindings: [{ ...TOGGLE_KEYBINDING, allow_in_editor: true }] } }), + ]; + const handler = vi.fn(); + pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler); + + renderHook(() => usePluginShortcuts()); + const input = document.createElement("input"); + document.body.appendChild(input); + pressKey("e", { ctrlKey: true, shiftKey: true }, input); + input.remove(); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("dispatches inside a contenteditable composer when the binding declared allow_in_editor", () => { + mockItems = [ + makePlugin({ ui: { keybindings: [{ ...TOGGLE_KEYBINDING, allow_in_editor: true }] } }), + ]; + const handler = vi.fn(); + pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler); + + renderHook(() => usePluginShortcuts()); + const editor = document.createElement("div"); + editor.contentEditable = "true"; + Object.defineProperty(editor, "isContentEditable", { value: true }); + document.body.appendChild(editor); + pressKey("e", { ctrlKey: true, shiftKey: true }, editor); + editor.remove(); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("ignores the editor opt-in when a user override dropped the modifier", () => { + // The shortcut recorder accepts a bare key, so an override can strip the + // modifier the manifest validator required. Honouring allow_in_editor + // against that resolved combo would swallow every "e" typed into an input. + mockItems = [ + makePlugin({ ui: { keybindings: [{ ...TOGGLE_KEYBINDING, allow_in_editor: true }] } }), + ]; + mockOverrides = { [`plugin:${PLUGIN_ID}:toggle`]: { key: "e" } }; + const handler = vi.fn(); + pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler); + + renderHook(() => usePluginShortcuts()); + const input = document.createElement("input"); + document.body.appendChild(input); + pressKey("e", {}, input); + input.remove(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("still honours the opt-in when the override keeps a modifier", () => { + mockItems = [ + makePlugin({ ui: { keybindings: [{ ...TOGGLE_KEYBINDING, allow_in_editor: true }] } }), + ]; + mockOverrides = { + [`plugin:${PLUGIN_ID}:toggle`]: { key: "u", modifiers: { ctrlOrCmd: true } }, + }; + const handler = vi.fn(); + pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler); + + renderHook(() => usePluginShortcuts()); + const input = document.createElement("input"); + document.body.appendChild(input); + pressKey("u", { ctrlKey: true }, input); + input.remove(); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("keeps skipping editables for a sibling binding that did not opt in", () => { + mockItems = [ + makePlugin({ + ui: { + keybindings: [ + { ...TOGGLE_KEYBINDING, allow_in_editor: true }, + { id: "open", default: OPEN_COMBO, description: "Open" }, + ], + }, + }), + ]; + const optedIn = vi.fn(); + const optedOut = vi.fn(); + pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", optedIn); + pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", optedOut); + + renderHook(() => usePluginShortcuts()); + const input = document.createElement("input"); + document.body.appendChild(input); + pressKey("e", { ctrlKey: true, shiftKey: true }, input); + pressKey("o", { ctrlKey: true, shiftKey: true }, input); + input.remove(); + + expect(optedIn).toHaveBeenCalledTimes(1); + expect(optedOut).not.toHaveBeenCalled(); + }); +}); + describe("usePluginShortcuts handler exceptions", () => { beforeEach(() => { mockOverrides = {}; diff --git a/apps/web/hooks/use-plugin-shortcuts.ts b/apps/web/hooks/use-plugin-shortcuts.ts index 3ec718059f..407c77b91a 100644 --- a/apps/web/hooks/use-plugin-shortcuts.ts +++ b/apps/web/hooks/use-plugin-shortcuts.ts @@ -12,7 +12,7 @@ import { resolveShortcutEntry, } from "@/lib/keyboard/plugin-shortcuts"; import { comboKey } from "@/lib/keyboard/shortcut-conflicts"; -import { SHORTCUTS } from "@/lib/keyboard/constants"; +import { SHORTCUTS, type KeyboardShortcut } from "@/lib/keyboard/constants"; /** * Central shortcuts that are not in `CONFIGURABLE_SHORTCUTS` (so they have no @@ -96,11 +96,10 @@ export function usePluginShortcuts(): void { // preventDefault) — core wins, so bail out without invoking any // plugin handler for the same keypress. if (event.defaultPrevented) return; - if (isEditableKeydownTarget(event)) return; const overrides = appStore.getState().userSettings .keyboardShortcuts as StoredShortcutOverrides; - dispatchMatchingPluginShortcuts(event, items, overrides); + dispatchMatchingPluginShortcuts(event, items, overrides, isEditableKeydownTarget(event)); }; // Capture phase so plugin shortcuts win before focus-trapped surfaces @@ -135,6 +134,32 @@ function buildCoreComboKeySet( return keys; } +/** + * A combo the user could not produce by ordinary typing. `shift` does not + * count: shift+a is just a capital A. + */ +function hasNonShiftModifier(shortcut: KeyboardShortcut): boolean { + const m = shortcut.modifiers; + return Boolean(m && (m.ctrlOrCmd || m.ctrl || m.cmd || m.alt)); +} + +/** + * Collects the namespaced ids of every declared keybinding that set + * `allow_in_editor`, so the dispatcher can let just those through while an + * input, textarea or contenteditable holds focus. + */ +function buildAllowInEditorSet( + plugins: Parameters[0], +): Set { + const ids = new Set(); + for (const plugin of plugins) { + for (const keybinding of plugin.ui?.keybindings ?? []) { + if (keybinding.allow_in_editor) ids.add(`plugin:${plugin.id}:${keybinding.id}`); + } + } + return ids; +} + /** * Dispatches `event` to every registered plugin keybinding handler whose * effective combo matches. Iterates in `pluginRegistry.getKeybindingHandlers()` @@ -157,12 +182,14 @@ function dispatchMatchingPluginShortcuts( event: KeyboardEvent, plugins: Parameters[0], overrides: StoredShortcutOverrides, + targetIsEditable: boolean, ): void { const entryById = new Map( buildConfigurableShortcutEntries(plugins) .filter((entry) => entry.source === "plugin") .map((entry) => [entry.id, entry] as const), ); + const allowedInEditor = buildAllowInEditorSet(plugins); const isMacPlatform = isMac(); const coreComboKeys = buildCoreComboKeySet(overrides, isMacPlatform); @@ -170,8 +197,17 @@ function dispatchMatchingPluginShortcuts( for (const { pluginId, id, handler } of pluginRegistry.getKeybindingHandlers()) { const entry = entryById.get(`plugin:${pluginId}:${id}`); if (!entry) continue; + // Skipping while the user is typing is the default so a plugin can never + // shadow an ordinary keystroke. A binding that exists to act on the + // focused composer opts in through the manifest. + if (targetIsEditable && !allowedInEditor.has(`plugin:${pluginId}:${id}`)) continue; const shortcut = resolveShortcutEntry(entry, overrides); + // The manifest validator requires a ctrl/cmd/mod/alt modifier for an + // editor-enabled binding, but the effective combo may be a user override + // and the shortcut recorder accepts a bare key. Re-check the resolved + // combo, or a rebind to plain "m" would eat every m the user types. + if (targetIsEditable && !hasNonShiftModifier(shortcut)) continue; if (!matchesShortcut(event, shortcut)) continue; const pluginComboKey = comboKey(shortcut, isMacPlatform); diff --git a/apps/web/lib/plugins/composer-capability.test.tsx b/apps/web/lib/plugins/composer-capability.test.tsx new file mode 100644 index 0000000000..b25df304db --- /dev/null +++ b/apps/web/lib/plugins/composer-capability.test.tsx @@ -0,0 +1,162 @@ +import { StrictMode, type ReactNode } from "react"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + composerIdentity, + composerInsertionText, + useStablePluginComposerCapability, +} from "./composer-capability"; + +/** One mounted task-chat composer, used wherever the identity is incidental. */ +const COMPOSER = "task-chat:t1:s1"; + +function strictWrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("composerInsertionText", () => { + it("trims and ignores an empty transcript", () => { + expect(composerInsertionText(" ", "a")).toBe(""); + expect(composerInsertionText("", "")).toBe(""); + }); + + it("adds one space only when the preceding character is not whitespace", () => { + expect(composerInsertionText(" world ", "o")).toBe(" world"); + expect(composerInsertionText("world", " ")).toBe("world"); + expect(composerInsertionText("world", "\n")).toBe("world"); + expect(composerInsertionText("world", "")).toBe("world"); + }); +}); + +describe("composerIdentity", () => { + it("separates surfaces, tasks and sessions", () => { + const ids = [ + composerIdentity("task-chat", "t1", "s1"), + composerIdentity("task-chat", "t1", "s2"), + composerIdentity("task-chat", "t2", "s1"), + composerIdentity("quick-chat", "t1", "s1"), + composerIdentity("task-create", null, null), + composerIdentity("new-session", "t1", null), + ]; + expect(new Set(ids).size).toBe(ids.length); + }); + + it("is stable for the same composer", () => { + expect(composerIdentity("task-chat", "t1", "s1")).toBe( + composerIdentity("task-chat", "t1", "s1"), + ); + }); +}); + +describe("useStablePluginComposerCapability", () => { + it("keeps one capability object across re-renders", () => { + const { result, rerender } = renderHook( + (submittable: boolean) => + useStablePluginComposerCapability( + { + insertText: () => true, + focus: () => true, + submit: async () => submittable, + }, + COMPOSER, + ), + { initialProps: false }, + ); + + const first = result.current; + rerender(true); + expect(result.current).toBe(first); + }); + + it("revalidates the native gate at call time rather than at handout time", async () => { + const { result, rerender } = renderHook( + (submittable: boolean) => + useStablePluginComposerCapability( + { + insertText: () => true, + focus: () => true, + submit: async () => submittable, + }, + COMPOSER, + ), + { initialProps: false }, + ); + + // A plugin that captured the capability while submit was blocked... + const captured = result.current; + await expect(captured.submit()).resolves.toEqual({ status: "blocked" }); + + // ...still submits once the native form becomes submittable. + rerender(true); + await expect(captured.submit()).resolves.toEqual({ status: "submitted" }); + }); + + it("survives a StrictMode remount so a long-running plugin action still works", async () => { + const insertText = vi.fn(() => true); + const { result } = renderHook( + () => + useStablePluginComposerCapability( + { + insertText, + focus: () => true, + submit: async () => true, + }, + COMPOSER, + ), + { wrapper: strictWrapper }, + ); + + expect(result.current.insertText("hello")).toEqual({ status: "inserted" }); + await expect(result.current.submit()).resolves.toEqual({ status: "submitted" }); + expect(insertText).toHaveBeenCalledWith("hello"); + }); + + it("revokes the previous handle when the composer's identity changes", async () => { + // Task chat is not remounted on a session switch (ChatInputContainer is + // keyed only by clarificationKey), so without an identity key a handle + // captured on session A would retarget session B's editor. + const { result, rerender } = renderHook( + (identity: string) => + useStablePluginComposerCapability( + { + insertText: () => true, + focus: () => true, + submit: async () => true, + }, + identity, + ), + { initialProps: COMPOSER }, + ); + + const capturedOnSessionA = result.current; + rerender("task-chat:t1:s2"); + + expect(result.current).not.toBe(capturedOnSessionA); + expect(capturedOnSessionA.insertText("late transcript")).toEqual({ status: "unavailable" }); + await expect(capturedOnSessionA.submit()).resolves.toEqual({ status: "unavailable" }); + // The freshly issued handle drives the new composer normally. + expect(result.current.insertText("hello")).toEqual({ status: "inserted" }); + }); + + it("fails closed once the composer unmounts", async () => { + const insertText = vi.fn(() => true); + const { result, unmount } = renderHook(() => + useStablePluginComposerCapability( + { + insertText, + focus: () => true, + submit: async () => true, + }, + COMPOSER, + ), + ); + + const captured = result.current; + unmount(); + + expect(captured.insertText("hello")).toEqual({ status: "unavailable" }); + expect(captured.focus()).toEqual({ status: "unavailable" }); + await expect(captured.submit()).resolves.toEqual({ status: "unavailable" }); + expect(insertText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/plugins/composer-capability.ts b/apps/web/lib/plugins/composer-capability.ts index 85577a8599..082ae66ff1 100644 --- a/apps/web/lib/plugins/composer-capability.ts +++ b/apps/web/lib/plugins/composer-capability.ts @@ -1,3 +1,4 @@ +import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; import type { PluginComposerCapability } from "./types"; type NativeComposerOperations = { @@ -6,12 +7,29 @@ type NativeComposerOperations = { submit(): Promise; }; -export function createPluginComposerCapability(operations: NativeComposerOperations): { +export type PluginComposerCapabilityHandle = { api: PluginComposerCapability; + /** + * Marks the capability usable and returns the matching teardown. Wire this + * as an effect body (`useEffect(() => handle.attach(), [handle])`) rather + * than calling `revoke` from a cleanup directly: React StrictMode mounts, + * unmounts and remounts the same component instance, so a cleanup-only + * revocation would permanently kill a handle whose composer is still on + * screen. + */ + attach(): () => void; revoke(): void; -} { +}; + +export function createPluginComposerCapability( + operations: NativeComposerOperations, +): PluginComposerCapabilityHandle { let active = true; + const revoke = () => { + active = false; + }; + return { api: { insertText(text) { @@ -28,8 +46,87 @@ export function createPluginComposerCapability(operations: NativeComposerOperati return (await operations.submit()) ? { status: "submitted" } : { status: "blocked" }; }, }, - revoke() { - active = false; + attach() { + active = true; + return revoke; }, + revoke, }; } + +/** + * Word-boundary spacing rule every composer adapter applies to an inserted + * transcript, so the TipTap chat composer and the plain-textarea creation + * composers behave identically: the text is trimmed, and a single space is + * added when the character immediately before the insertion point is not + * already whitespace. Returns "" for an insertion the caller should ignore. + */ +export function composerInsertionText(text: string, charBefore: string): string { + const trimmed = text.trim(); + if (!trimmed) return ""; + const needsLeadingSpace = charBefore !== "" && !/\s/.test(charBefore); + return needsLeadingSpace ? ` ${trimmed}` : trimmed; +} + +/** + * The key that distinguishes one mounted composer from another, for + * `useStablePluginComposerCapability`. Every part matters: composers are not + * remounted when the user switches task or session, so dropping a segment + * lets a handle captured on one conversation act on the next. + */ +export function composerIdentity( + surface: string, + taskId: string | null, + sessionId: string | null, +): string { + return `${surface}:${taskId ?? ""}:${sessionId ?? ""}`; +} + +/** + * Builds the `PluginComposerCapability` a mounted composer hands to its + * plugin action slot. + * + * The returned object is stable across ordinary re-renders. That is the whole + * point: a plugin action is inherently asynchronous (record, upload, + * transcribe, then insert and maybe submit), so it holds the capability across + * many host re-renders. Rebuilding it whenever `disabled`, `submittable` or the + * native submit callback changed would revoke the object the plugin is still + * holding and turn a completed transcription into `unavailable`. + * + * Liveness comes from the operations ref instead: each render republishes the + * current closures, so `submit()` revalidates the native gate at call time + * rather than against a snapshot taken when the plugin first got the object. + * The ref is published in a layout effect so a handler running after paint + * cannot read the previous render's gate. + * + * `identity` is what stops that stability from becoming a leak. A composer is + * not remounted when the user switches task or session — task chat's container + * is keyed only by its clarification key — so without it, a handle captured + * while recording on one session would happily insert into the next one, which + * the composer contract forbids. Pass whatever distinguishes one mounted + * composer from another (surface, task, session); changing it revokes the old + * handle and issues a new one, while ordinary disabled/submittable re-renders + * leave it alone. + */ +export function useStablePluginComposerCapability( + operations: NativeComposerOperations, + identity: string, +): PluginComposerCapability { + const latest = useRef(operations); + useLayoutEffect(() => { + latest.current = operations; + }); + + const handle = useMemo( + () => + createPluginComposerCapability({ + insertText: (text) => latest.current.insertText(text), + focus: () => latest.current.focus(), + submit: () => latest.current.submit(), + }), + [identity], + ); + useEffect(() => handle.attach(), [handle]); + + return handle.api; +} diff --git a/apps/web/lib/types/plugins.ts b/apps/web/lib/types/plugins.ts index e680d8fe61..5429a239cf 100644 --- a/apps/web/lib/types/plugins.ts +++ b/apps/web/lib/types/plugins.ts @@ -40,6 +40,12 @@ export interface PluginKeybinding { id: string; default: string; description: string; + /** + * Lets this binding fire while an input, textarea or contenteditable has + * focus. Off by default so a plugin cannot shadow ordinary typing; the + * backend only accepts it on a combo carrying a ctrl/cmd/mod/alt modifier. + */ + allow_in_editor?: boolean; } export interface PluginUISection { diff --git a/docs/plans/plugin-voice-extraction-host/plan.md b/docs/plans/plugin-voice-extraction-host/plan.md index 843ddecf2b..fd014a6219 100644 --- a/docs/plans/plugin-voice-extraction-host/plan.md +++ b/docs/plans/plugin-voice-extraction-host/plan.md @@ -1,7 +1,7 @@ --- spec: docs/specs/plugins/voice-extraction-host.md created: 2026-08-11 -status: building +status: complete --- # Implementation Plan: Voice Plugin Host Prerequisites @@ -113,7 +113,7 @@ Wave 2: Wave 3: -- [ ] [Task 06: Document and verify extraction prerequisites](task-06-docs-e2e-verification.md) (depends on Tasks 03-05) +- [x] [Task 06: Document and verify extraction prerequisites](task-06-docs-e2e-verification.md) (depends on Tasks 03-05) Tasks in the same wave are parallel-safe only where their owned files remain disjoint. Execution stays sequential unless the user explicitly authorizes subagents. diff --git a/docs/plans/plugin-voice-extraction-host/task-06-docs-e2e-verification.md b/docs/plans/plugin-voice-extraction-host/task-06-docs-e2e-verification.md index 62817e6a31..929cb5e310 100644 --- a/docs/plans/plugin-voice-extraction-host/task-06-docs-e2e-verification.md +++ b/docs/plans/plugin-voice-extraction-host/task-06-docs-e2e-verification.md @@ -1,7 +1,7 @@ --- id: "06-docs-e2e-verification" title: "Document and verify extraction prerequisites" -status: pending +status: done wave: 3 depends_on: ["03-chat-composer-adapters", "04-creation-composer-adapters", "05-authenticated-webhook-handler"] plan: "plan.md" @@ -52,3 +52,24 @@ scrolling, and the document has no horizontal overflow. through a registered fixture plugin slot and observe the created message/task/session. - Do not remove or weaken current Voice Mode tests. Extraction and core removal belong to the later dedicated plugin task. + +## Outcome + +The E2E pass found four defects the wave-2 unit tests could not see, all fixed in the same change: + +1. `ChatInputToolbar` never forwarded `composerCapability` to `MobileChatInputToolbar`, and + `DesktopChatInputToolbar` never forwarded it to `DesktopRightSection`. Task chat therefore always + received the always-`unavailable` fallback on every presentation; only the creation forms worked. + The existing toolbar unit test mocks the slot away, so nothing caught it. Covered now by + `chat-input-toolbar-composer.test.tsx`, which renders the real top-level toolbar and asserts on + what the slot received at desktop, mobile and tablet. +2. Both composer adapters rebuilt and revoked the capability on nearly every render, so an + asynchronous plugin action (record, transcribe, insert) failed with `unavailable` the moment the + host re-rendered. `useStablePluginComposerCapability` now issues one object per mount and reads + live state through a ref, which is also what makes "revalidate at call time" true rather than + aspirational. +3. The TipTap adapter derived the word-boundary spacing rule by indexing `getValue()` (markdown) + with `getSelectionStart()` (a ProseMirror doc position), so the leading space was silently + dropped. `TipTapInputHandle.getCharBefore()` now reads it from the document. +4. `submit()` ignored `hasContent`, so it submitted an empty draft while the same slot advertised + `submittable: false`. diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index c7361f584b..4f5a06e84b 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -449,10 +449,18 @@ interface PluginRegistry { registerWsHandler(action: string, handler: (payload: unknown) => void): void; // Binds a handler to a keybinding declared in this plugin's manifest - // (ui.keybindings[].id — { id, default, description }, see manifest schema). + // (ui.keybindings[].id — { id, default, description, allow_in_editor? }, + // see manifest schema). // The host resolves the effective combo (user override if the user // rebound it, else the manifest default) and dispatches globally, skipping - // editable targets the same way core app shortcuts do. Combos are + // editable targets the same way core app shortcuts do — unless that entry + // declared `allow_in_editor: true`, which lets it fire while an input, + // textarea or contenteditable has focus. That opt-in exists for bindings + // whose whole job is to act on the focused composer (dictation); the + // manifest validator only accepts it on a combo carrying a + // ctrl/cmd/mod/alt modifier, and the dispatcher re-checks the *resolved* + // combo, so a user override that drops the modifier silently falls back to + // skipping editables rather than swallowing ordinary keystrokes. Combos are // user-overridable in Settings > Keyboard Shortcuts, namespaced // `plugin:{pluginId}:{id}`. Binding an id the manifest didn't declare still // stores the handler (a console warning is logged) since the dispatcher's diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 64e7c9ec7d..35bb7dbf3b 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -232,7 +232,7 @@ bookkeeping is host-internal; plugins do not call lifecycle methods. | registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins//...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) | | registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) | | registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) | -| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; editable targets/core shortcuts win | registry.registerKeybinding("open-panel", () => host.openModal(...)) | +| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; core shortcuts win, and editable targets are skipped unless that entry set ui.keybindings[].allow_in_editor | registry.registerKeybinding("open-panel", () => host.openModal(...)) | | registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) | | registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" nests inside the card's Edit submenu, "primary" renders as a flat top-level item between "Move to"/"Send to workflow" and "Link" | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: doEnhance }) | | registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) | diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index 85eb0a93a4..765a0f30e2 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -116,6 +116,7 @@ ui: # optional native frontend plugin - id: "open-panel" # plugin-local: ^[a-z0-9][a-z0-9-]*$ default: "mod+shift+j" # combo grammar, see field reference description: "Open the Acme panel" + allow_in_editor: false # optional; true lets it fire while typing ``` ## Field reference @@ -171,6 +172,7 @@ ui: # optional native frontend plugin | `ui.keybindings[].id` | yes* | string | Stable, plugin-local slug (*required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]*$` and be unique within this plugin's own `ui.keybindings` list, not globally; the effective shortcut is namespaced `plugin:{pluginId}:{id}`. | | `ui.keybindings[].default` | yes* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key; the browser reports the shifted glyph for those keys, so the combo could never match. | | `ui.keybindings[].description` | yes* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. | +| `ui.keybindings[].allow_in_editor` | no | boolean | Lets this binding fire while an `input`, `textarea` or contenteditable holds focus. Defaults to `false`, so a plugin cannot shadow ordinary typing. Only accepted on a combo carrying a `ctrl`/`cmd`/`mod`/`alt` modifier; `shift` alone does not count, since `shift+a` is just a capital A. Kandev re-applies that requirement to the *effective* combo, so a user override that drops the modifier reverts this binding to the default skip-while-typing behaviour rather than intercepting ordinary keystrokes. Declare it only when the binding exists to act on the focused field (dictation into the composer is the canonical case). | `ui.pages` is declarative manifest metadata only and is not currently rendered by the frontend. A native bundle's runtime nav items, icons, routes, named diff --git a/docs/specs/plugins/voice-extraction-host.md b/docs/specs/plugins/voice-extraction-host.md index 09d7b7861e..81591ec477 100644 --- a/docs/specs/plugins/voice-extraction-host.md +++ b/docs/specs/plugins/voice-extraction-host.md @@ -91,8 +91,13 @@ interface PluginComposerSlotProps { ``` The host re-renders slot props when native state changes. `submit()` always revalidates native state -at call time. The host may replace the capability object when a surface is remounted; methods on the -old object return `unavailable`. +at call time, reading the live draft rather than the last render's snapshot, so a plugin that inserts +and submits in one callback is not told `blocked` for text it just inserted. + +The host replaces the capability object whenever the composer's identity changes: its surface, task +or session. Methods on the superseded object return `unavailable`. Identity matters separately from +mounting because a composer is re-rendered, not remounted, when the user switches task or session, so +without it a handle captured while recording on one conversation would act on the next. ### Authenticated webhooks