fix(plugins): make the composer capability usable end to end (#2575)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-12 20:29:18 +01:00
committed by GitHub
parent 4257307063
commit fa7d88bee6
24 changed files with 1623 additions and 83 deletions
@@ -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({
@@ -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")
}
}
@@ -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
@@ -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 {
@@ -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<TaskFormInputsHandle>();
render(
<TaskFormInputs
isSessionMode={false}
autoFocus={false}
initialDescription=""
onDescriptionChange={() => {}}
onKeyDown={() => {}}
descriptionValueRef={ref}
onVoiceAutoSend={onVoiceAutoSend}
/>,
{ wrapper: Wrapper },
);
const composer = lastPluginSlotProps().composer;
let result: Awaited<ReturnType<typeof composer.submit>> | 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");
@@ -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<string>;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
insertAtCursor: (text: string) => void;
submit?: () => boolean | Promise<boolean>;
}) {
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 (
<PluginSlot
name={args.isSessionMode ? "new-session-input-actions" : "task-create-input-actions"}
slotProps={{
surface: args.isSessionMode ? "new-session" : "task-create",
surface,
presentation: isMobile ? "mobile" : "desktop",
taskId: args.taskId,
activeSessionId: null,
sessionIds: [],
disabled: args.disabled,
submittable: !args.disabled && args.description.trim().length > 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,
@@ -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
@@ -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: () => <button type="button">model</button>,
}));
vi.mock("@/components/task/mode-selector", () => ({
ModeSelector: () => <button type="button">mode</button>,
}));
vi.mock("@/components/task/sessions-dropdown", () => ({
SessionsDropdown: () => <button type="button">sessions</button>,
}));
vi.mock("@/components/task/chat/token-usage-display", () => ({
TokenUsageDisplay: () => <span />,
}));
vi.mock("@/components/enhance-prompt-button", () => ({
EnhancePromptButton: () => <button type="button">Enhance</button>,
}));
vi.mock("./context-popover", () => ({
ContextPopover: ({ trigger }: { trigger: React.ReactNode }) => <>{trigger}</>,
}));
vi.mock("./implement-plan-button", () => ({
ImplementPlanButton: () => <button type="button">Implement plan</button>,
}));
vi.mock("./reset-context-button", () => ({
ResetContextButton: () => <button type="button">Reset context</button>,
}));
vi.mock("./voice-input-button", () => ({
VoiceInputButton: () => <button type="button">Voice</button>,
}));
// Records what the slot was handed instead of rendering plugin components.
vi.mock("./chat-input-plugin-actions", () => ({
ChatInputPluginActions: (props: Record<string, unknown>) => {
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<ChatInputToolbarProps> = {}) {
return render(
<StateProvider>
<ChatInputToolbar
planModeEnabled={false}
onPlanModeChange={() => {}}
sessionId="s1"
taskId="t1"
taskDescription=""
isAgentBusy={false}
hasContent
isDisabled={false}
isSending={false}
onCancel={() => {}}
onSubmit={() => {}}
hidePlanMode
onAttachFiles={() => {}}
{...overrides}
/>
</StateProvider>,
);
}
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(
<StateProvider>
<ChatInputToolbar
planModeEnabled={false}
onPlanModeChange={() => {}}
sessionId="s1"
taskId="t1"
taskDescription=""
isAgentBusy={false}
hasContent
isDisabled={false}
isSending={false}
onCancel={() => {}}
onSubmit={() => {}}
hidePlanMode
onAttachFiles={() => {}}
composerCapability={composer}
/>
</StateProvider>,
);
expect(lastSlotProps().submittable).toBe(true);
});
});
@@ -293,6 +293,8 @@ export function DesktopChatInputToolbar(props: DesktopToolbarProps) {
submitShortcut={props.submitShortcut}
onVoiceTranscript={props.onVoiceTranscript}
onVoiceAutoSend={props.onVoiceAutoSend}
composerCapability={props.composerCapability}
composerSurface={props.composerSurface}
/>
</div>
);
@@ -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}
/>
);
}
@@ -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;
@@ -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<void> {
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<void> {
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);
});
});
@@ -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<void> {
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<void> {
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<void> {
await target.click();
for (let i = 0; i < steps; i++) await target.press("ArrowLeft");
}
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
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);
});
});
+112
View File
@@ -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 = {};
+39 -3
View File
@@ -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<typeof buildConfigurableShortcutEntries>[0],
): Set<string> {
const ids = new Set<string>();
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<typeof buildConfigurableShortcutEntries>[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);
@@ -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 <StrictMode>{children}</StrictMode>;
}
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();
});
});
+101 -4
View File
@@ -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<boolean>;
};
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;
}
+6
View File
@@ -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 {
@@ -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.
@@ -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`.
+10 -2
View File
@@ -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
+1 -1
View File
@@ -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/<id>/...; 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 }) |
+2
View File
@@ -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
+7 -2
View File
@@ -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