fix(plugins): make the composer capability usable end to end (#2575)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user