diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index f1cf6f93f..8b1ab28bf 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -9,7 +9,7 @@ import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react"; -import { SendAction, usePopoverDismissal } from "@chopin/editor"; +import { MAX_RESEARCH_BRIEF, SendAction, usePopoverDismissal } from "@chopin/editor"; import { MENTION } from "@chopin/protocol/address"; import { ArchiveIcon, InfoIcon, LoaderIcon, LockIcon, PlusIcon, WarningIcon } from "@chopin/icons"; import { DraftInput } from "./draft-input"; @@ -17,6 +17,17 @@ import type { DraftInputHandle } from "./draft-input"; import { ModeSwitch } from "./mode-switch"; import "./composer.css"; +import { CommandPicker } from "./command-picker"; +import { + CHAT_COMMANDS, + commandBrief, + commandKeyAction, + commandText, + commandTrigger, + commandTriggerKey, + draftCommand, + filterCommands, +} from "./commands"; import { MentionPicker } from "./mention-picker"; import { chatAuthors, @@ -62,6 +73,8 @@ import plannerResume from "../assets/icons/planner-resume.svg"; import type { Chat as Wire, ConversationPlan } from "@chopin/protocol"; import type { Repository } from "../api"; +import type { ResearchLaunchBlock, ResearchLaunchResult } from "@chopin/editor"; +import type { ChatCommand } from "./commands"; import type { MentionCandidate } from "./mentions"; import type { ComposerDraft, ReferenceTarget } from "./references"; import type { Wire as Socket } from "../wire"; @@ -97,6 +110,13 @@ export type ChatProps = { sourceDestination?: ChatDestination; /** Opens Decisions, where a waiting workflow's questions are. */ onShowDecisions?: () => void; + /** + * Opens the document's research composer with this brief, when this + * document offers research, and says why when it cannot. + */ + onResearch?: (brief: string) => Promise; + /** Focuses the research draft already open in the document. */ + onShowResearch?: () => void; /** Replaces the composer when this viewer cannot chat at all, such as in an archived document. */ notice?: string; /** One calm line shown while the transcript has loaded and is still empty. */ @@ -134,6 +154,8 @@ export function Chat( notice, emptyNotice, onShowDecisions, + onResearch, + onShowResearch, referencesEnabled, repository, room, @@ -154,9 +176,12 @@ export function Chat( }); let [submitting, setSubmitting] = useState(false); let [sendError, setSendError] = useState(); + let [researchBlock, setResearchBlock] = useState(); + let researching = useRef(false); let [selection, setSelection] = useState({ start: 0, end: 0 }); let [dismissedPicker, setDismissedPicker] = useState(); let [mentionCursor, setMentionCursor] = useState<{ key?: string; index: number }>({ index: 0 }); + let [commandCursor, setCommandCursor] = useState<{ key?: string; index: number }>({ index: 0 }); let textarea = useRef(null); let composerRoot = useRef(null); let [mode, setMode] = useState(false); @@ -167,6 +192,7 @@ export function Chat( draftRef.current = draft; let pickerId = useId(); let mentionPickerId = useId(); + let commandPickerId = useId(); let instructionsId = useId(); let cueId = useId(); let synchronized = useRef(undefined); @@ -227,15 +253,26 @@ export function Chat( let activeMention: MentionCandidate | undefined = mentionOpen ? mentionOptions[mentionActive] : undefined; + let command = composerReady && !submitting + ? commandTrigger(draft.text, selection.start, selection.end) + : undefined; + let commandKey = command ? commandTriggerKey(command) : undefined; + let commandOptions = command && onResearch ? filterCommands(CHAT_COMMANDS, command.query) : []; + let commandOpen = commandOptions.length > 0 && commandKey !== dismissedPicker; + let commandActive = commandCursor.key === commandKey + ? Math.min(commandCursor.index, commandOptions.length - 1) + : 0; + // Without research here, a `/research` draft is held with a notice rather than posted. + let blockedCommand = !onResearch && !!draftCommand(draft.text); // Escape keeps the draft and the composer's focus; only the picker closes. usePopoverDismissal( - mentionOpen || pickerOpen, + commandOpen || mentionOpen || pickerOpen, target => composerRoot.current?.contains(target) && !!(target as Element).closest?.( - "[contenteditable], [data-chat-mention-picker], [data-chat-reference-picker]", + "[contenteditable], [data-chat-command-picker], [data-chat-mention-picker], [data-chat-reference-picker]", ), - () => setDismissedPicker(mentionOpen ? mentionKey : triggerKey), + () => setDismissedPicker(commandOpen ? commandKey : mentionOpen ? mentionKey : triggerKey), ); let activeOption = picker.options.length === 0 ? undefined @@ -323,9 +360,40 @@ export function Chat( setHistoryKey(current => current + 1); }; + /** `text` replaces a draft that is not yet the command, so Retry can reopen it. */ + let launchResearch = (brief: string, text?: string) => { + if (!onResearch || researching.current) return; + researching.current = true; + setResearchBlock(undefined); + setSendError(undefined); + let started = draftRef.current; + void onResearch(brief).then(result => { + // The draft is cleared only once the document composer holds the brief. + // Compare text: hiding the pane can re-emit an unchanged draft. + if (draftRef.current.text !== started.text) return; + let next = result.ok ? "" : text; + if (next !== undefined) { + let revised = reviseComposerDraft(started, next, []); + draftRef.current = revised; + setDraft(revised); + setSelection({ start: next.length, end: next.length }); + setDismissedPicker(undefined); + if (!result.ok) pendingCaret.current = next.length; + } + if (!result.ok) setResearchBlock(result.reason); + }).finally(() => { + researching.current = false; + }); + }; + let submit = () => { if (submission.current || !composerReady || !wire) return; let current = draftRef.current; + if (draftCommand(current.text)) { + // `#` references stay in the brief as their visible titles. + launchResearch(commandBrief(current.text)); + return; + } if (!current.text.trim()) return; let submitted = prepareDraftSubmission(current); let prefix = effectiveMode && !addressedOutsideReferences(submitted.text, submitted.references) @@ -453,6 +521,16 @@ export function Chat( let moveMention = (to: (index: number) => number) => setMentionCursor({ key: mentionKey, index: to(mentionActive) }); + let chooseCommand = (choice: ChatCommand) => launchResearch("", commandText(choice)); + + let researchCopy: Record = { + "too-long": `Briefs can be up to ${MAX_RESEARCH_BRIEF.toLocaleString("en-US")} characters`, + drafting: "A research request is already being written in the document", + "read-only": "This document is read-only", + disconnected: "Reconnecting… try again in a moment", + unavailable: "Research couldn’t open in the document", + }; + let transcriptView = ( )} - {!readonly && !archived && (sendError || !composerReady || !agent) && ( + {!readonly && !archived + && (sendError || !composerReady || researchBlock || blockedCommand || !agent) && (
{sendError ?? (!composerReady ? connected ? "Synchronizing…" : connectionLost ? "Connection lost" : "Connecting…" + : researchBlock + ? researchCopy[researchBlock] + : blockedCommand + ? "Research isn’t available in this document" : "Chopin unavailable")} {sendError @@ -544,13 +627,25 @@ export function Chat( Retry ) - : !composerReady && wire && ( + : !composerReady + ? wire && ( + ) + : researchBlock === "drafting" + ? onShowResearch && ( + + ) + : (researchBlock === "disconnected" || researchBlock === "unavailable") && ( + )}
)} @@ -571,6 +666,15 @@ export function Chat( : picker} /> )} + {commandOpen && ( + setCommandCursor({ key: commandKey, index })} + onSelect={chooseCommand} + options={commandOptions} + /> + )} {mentionOpen && ( { let composing = event.nativeEvent.isComposing || event.keyCode === 229; + let commandAction = commandOpen + ? commandKeyAction({ + key: event.key, + keyCode: event.keyCode, + isComposing: event.nativeEvent.isComposing, + shiftKey: event.shiftKey, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }, true) + : undefined; + if (commandAction === "next" || commandAction === "previous") { + let step = commandAction === "next" ? 1 : -1; + setCommandCursor({ + key: commandKey, + index: (commandActive + step + commandOptions.length) + % commandOptions.length, + }); + event.preventDefault(); + return; + } + if (commandAction === "select") { + chooseCommand(commandOptions[commandActive]!); + event.preventDefault(); + return; + } if (!composing && event.key === "Tab" && !event.shiftKey) { setDismissedPicker(mentionKey ?? triggerKey); return; @@ -785,7 +926,8 @@ export function Chat( diff --git a/apps/web/src/chat/command-picker.tsx b/apps/web/src/chat/command-picker.tsx new file mode 100644 index 000000000..e53209965 --- /dev/null +++ b/apps/web/src/chat/command-picker.tsx @@ -0,0 +1,50 @@ +import { MENU_ROW, MENU_ROW_OFF, MENU_ROW_ON, MENU_SURFACE } from "@chopin/editor"; + +import { referenceOptionId } from "./reference-picker"; + +import type { ChatCommand } from "./commands"; + +/** The document's `/` menu, anchored above the Chat composer. */ +export function CommandPicker( + { + active, + id, + onActive, + onSelect, + options, + }: { + active: number; + id: string; + onActive: (index: number) => void; + onSelect: (command: ChatCommand) => void; + options: readonly ChatCommand[]; + }, +) { + return ( +
+
+ {options.map((option, index) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/chat/commands.test.ts b/apps/web/src/chat/commands.test.ts new file mode 100644 index 000000000..c7460db9d --- /dev/null +++ b/apps/web/src/chat/commands.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { + CHAT_COMMANDS, + commandBrief, + commandKeyAction, + commandText, + commandTrigger, + draftCommand, + filterCommands, +} from "./commands"; + +describe("chat commands", () => { + test("open only while the draft is a single /query at the caret", () => { + expect(commandTrigger("/", 1)).toEqual({ query: "", end: 1 }); + expect(commandTrigger("/res", 4)).toEqual({ query: "res", end: 4 }); + expect(commandTrigger("/res ", 4)).toEqual({ query: "res", end: 4 }); + expect(commandTrigger("a /res", 6)).toBeUndefined(); + expect(commandTrigger("/research ", 10)).toBeUndefined(); + expect(commandTrigger("/usr/bin", 8)).toBeUndefined(); + expect(commandTrigger("/res", 2)).toBeUndefined(); + expect(commandTrigger("/res", 0, 4)).toBeUndefined(); + }); + + test("filter by keyword prefix", () => { + expect(filterCommands(CHAT_COMMANDS, "").map(command => command.id)).toEqual(["research"]); + expect(filterCommands(CHAT_COMMANDS, "RES").map(command => command.id)).toEqual(["research"]); + expect(filterCommands(CHAT_COMMANDS, "web").map(command => command.id)).toEqual(["research"]); + expect(filterCommands(CHAT_COMMANDS, "code")).toEqual([]); + }); + + test("recognise a research command so it is never posted", () => { + expect(draftCommand("/research")).toBe("research"); + expect(draftCommand(" /Research what changed?")).toBe("research"); + expect(draftCommand("/researcher")).toBeUndefined(); + expect(draftCommand("please /research this")).toBeUndefined(); + expect(draftCommand("@chopin /research pricing")).toBe("research"); + expect(draftCommand("@chopin please /research")).toBeUndefined(); + expect(commandText(CHAT_COMMANDS[0]!)).toBe("/research "); + }); + + test("the brief is what follows the command, references included as text", () => { + expect(commandBrief("/research")).toBe(""); + expect(commandBrief(" /research what changed in #Pricing? ")).toBe( + "what changed in #Pricing?", + ); + expect(commandBrief("@Chopin /research pricing\nand limits")).toBe("pricing\nand limits"); + }); + + test("Tab and Enter select; arrows move", () => { + expect(commandKeyAction({ key: "Tab" }, true)).toBe("select"); + expect(commandKeyAction({ key: "Tab", shiftKey: true }, true)).toBeUndefined(); + expect(commandKeyAction({ key: "Enter" }, true)).toBe("select"); + expect(commandKeyAction({ key: "ArrowDown" }, true)).toBe("next"); + expect(commandKeyAction({ key: "Enter", isComposing: true }, true)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/chat/commands.ts b/apps/web/src/chat/commands.ts new file mode 100644 index 000000000..97f9b04dc --- /dev/null +++ b/apps/web/src/chat/commands.ts @@ -0,0 +1,91 @@ +import { referencePickerKeyAction } from "./reference-picker"; + +import type { ReferencePickerKeyAction } from "./reference-picker"; + +export type ChatCommand = { + id: "research"; + label: string; + keywords: readonly string[]; +}; + +/** + * What `/` offers in Chat. + * + * Only commands that mean something here. Research is placed in the + * document, so Chat opens the document's research composer with the brief + * rather than posting the command as a message. + */ +export const CHAT_COMMANDS: readonly ChatCommand[] = [ + { id: "research", label: "Research", keywords: ["research", "web search"] }, +]; + +export type CommandTrigger = { query: string; end: number }; + +/** A `/query` that is the whole draft so far, with the caret at its end. */ +export function commandTrigger( + text: string, + selectionStart: number, + selectionEnd = selectionStart, +): CommandTrigger | undefined { + if (selectionStart !== selectionEnd) return undefined; + let match = /^\/([a-z]*)$/i.exec(text.slice(0, selectionStart)); + if (!match || /\S/.test(text.slice(selectionStart))) return undefined; + return { query: match[1]!, end: selectionStart }; +} + +export function commandTriggerKey(trigger: CommandTrigger): string { + return `command:${trigger.end}:${trigger.query}`; +} + +export function filterCommands( + commands: readonly ChatCommand[], + query: string, +): ChatCommand[] { + let prefix = query.toLowerCase(); + return commands.filter(command => command.keywords.some(keyword => keyword.startsWith(prefix))); +} + +const RESEARCH_COMMAND = /^\s*(?:@chopin\s+)?\/research(?=\s|$)\s*/i; + +/** + * The command a draft starts with, which must never be posted as a message. + * An `@chopin` in front is the same command, not a question for the Planner. + */ +export function draftCommand(text: string): ChatCommand["id"] | undefined { + return RESEARCH_COMMAND.test(text) ? "research" : undefined; +} + +/** What follows the command: the research brief. */ +export function commandBrief(text: string): string { + return text.replace(RESEARCH_COMMAND, "").trim(); +} + +export function commandText(command: ChatCommand): string { + return `/${command.id} `; +} + +/** Tab selects, as it does in the document's `/` menu. */ +export function commandKeyAction( + event: { + key: string; + keyCode?: number; + isComposing?: boolean; + shiftKey?: boolean; + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + }, + selectable: boolean, +): ReferencePickerKeyAction | undefined { + if ( + event.key === "Tab" + && selectable + && !event.isComposing + && event.keyCode !== 229 + && !event.shiftKey + && !event.altKey + && !event.ctrlKey + && !event.metaKey + ) return "select"; + return referencePickerKeyAction(event, selectable); +} diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 06c9e1430..c99018f30 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -14,6 +14,7 @@ import { PlanEditor, PlanStatus, QuestionnaireStore, + ResearchLauncher, selectDecisionView, ThreadStore, useHasPlanContent, @@ -49,7 +50,12 @@ import { useWorkspaceIds, useWorkspaceLayout, useWorkspaceState, Workspace } fro import { initialDocumentView, presentWorkspace, workspaceProfile } from "./workspace-model"; import type { ConversationPlan, Research, Session } from "@chopin/protocol"; -import type { DecisionView, DecisionViewState, PlanState } from "@chopin/editor"; +import type { + DecisionView, + DecisionViewState, + PlanState, + ResearchLaunchResult, +} from "@chopin/editor"; import type { DocumentMetadata } from "./document-actions"; import type { DocumentAction } from "./document-actions-menu"; import type { TitleEdit } from "./title-edit"; @@ -325,6 +331,7 @@ export function RoomWorkspace( let [questions] = useState(() => new QuestionnaireStore()); let [cardMeta] = useState(() => new CardMetaStore()); let [threads] = useState(() => new ThreadStore()); + let [researchLauncher] = useState(() => new ResearchLauncher()); let research = useMemo( () => new ResearchRequestStore({ @@ -603,6 +610,19 @@ export function RoomWorkspace( }); }; + let startResearch = async (brief: string): Promise => { + let checked = researchLauncher.check(brief); + if (!checked.ok) return checked; + selectDestination("plan"); + // The document may have been hidden; open once it has laid out. + await new Promise(resolve => requestAnimationFrame(resolve)); + return researchLauncher.open(brief); + }; + let showResearchDraft = () => { + selectDestination("plan"); + requestAnimationFrame(() => researchLauncher.reveal()); + }; + useEffect(() => { let editable = canEdit && !archivedAt; latestCanEdit.current = editable; @@ -820,6 +840,8 @@ export function RoomWorkspace( ? "Discuss this report here. Messages stay with the report." : undefined} onShowDecisions={() => selectDestination("decisions")} + onResearch={researchEnabled ? startResearch : undefined} + onShowResearch={showResearchDraft} people={peopleHere(members)} conversationPlan={conversation.state} conversationPlanJobs={conversation.jobs} @@ -930,6 +952,7 @@ export function RoomWorkspace( questions={questions} readOnly={!workspaceCanEdit} research={profile.research ? research : undefined} + researchLauncher={researchLauncher} scrollTop={planScrollTop} threads={threads} user={user} diff --git a/e2e/chat-commands.e2e.ts b/e2e/chat-commands.e2e.ts new file mode 100644 index 000000000..4b5c3a7f0 --- /dev/null +++ b/e2e/chat-commands.e2e.ts @@ -0,0 +1,95 @@ +import { chatInput, expectChatValue } from "./chat-input"; +import { content, expect, test } from "./room"; + +import type { Page } from "@playwright/test"; + +function chatPane(page: Page) { + return page.getByRole("complementary", { includeHidden: true, name: "Chat" }); +} + +function researchQuestion(page: Page) { + return page.getByRole("region", { name: "Research question", exact: true }) + .getByRole("textbox", { name: "Research question", exact: true }); +} + +test("/research in Chat opens the document's research composer instead of posting", async ({ join, page: first, seed }) => { + await seed("# Slash commands\n\nSome prose.\n"); + let sent: string[] = []; + first.on("websocket", socket => { + socket.on("framesent", frame => { + if (typeof frame.payload === "string" && frame.payload.includes('"chat:send"')) { + sent.push(frame.payload); + } + }); + }); + let page = await join("ana"); + let chat = chatPane(page); + let draft = chatInput(chat); + let menu = chat.getByRole("listbox", { name: "Commands" }); + let question = researchQuestion(page); + + await draft.click(); + await page.keyboard.type("/"); + await expect(menu).toBeVisible(); + await expect(draft).toHaveAttribute("aria-expanded", "true"); + await expect(menu.getByRole("option", { name: "Research", exact: true })) + .toHaveAttribute("aria-selected", "true"); + + // Escape closes only the menu; the draft and focus stay. + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(draft).toBeFocused(); + await expectChatValue(draft, "/"); + + // No command menu for a path. + await draft.fill("/usr/bin"); + await expect(menu).toHaveCount(0); + + // Choosing Research opens an empty composer after the last paragraph. + await draft.fill(""); + await page.keyboard.type("/res"); + await page.keyboard.press("Enter"); + await expect(question).toBeFocused(); + await expect(question).toHaveValue(""); + await expectChatValue(draft, ""); + await page.keyboard.press("Escape"); + await expect(question).toHaveCount(0); + await expect(content(page).getByText("Some prose.", { exact: true })).toBeVisible(); + + // Sending a typed brief opens the composer with it, reusing the empty last paragraph. + await draft.fill("/research what changed upstream?"); + await expect(menu).toHaveCount(0); + await draft.press("Enter"); + await expect(question).toBeFocused(); + await expect(question).toHaveValue("what changed upstream?"); + await expectChatValue(draft, ""); + await expect(content(page).locator(":scope > p")).toHaveCount(2); + // The caret follows the prefilled brief. + await page.keyboard.type("!"); + await expect(question).toHaveValue("what changed upstream?!"); + + // With a draft already open, Chat keeps its draft and points to that draft. + let notice = chat.getByRole("status").filter({ + hasText: "already being written in the document", + }); + await draft.fill("@chopin /research another question"); + await draft.press("Enter"); + await expect(notice).toBeVisible(); + await expectChatValue(draft, "@chopin /research another question"); + await chat.getByRole("button", { name: "Go to it", exact: true }).click(); + await expect(question).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(question).toHaveCount(0); + + // A brief longer than the composer accepts is refused in Chat. + await draft.fill(`/research ${"a".repeat(4097)}`); + await draft.press("Enter"); + await expect(chat.getByRole("status").filter({ hasText: "Briefs can be up to 4,096 characters" })) + .toBeVisible(); + await expect(question).toHaveCount(0); + await draft.fill(""); + + expect(sent).toEqual([]); + await expect(chat.locator("[data-chat-stack]").getByText("/research", { exact: false })) + .toHaveCount(0); +}); diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 9cadf82d9..18a601b69 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -41,11 +41,19 @@ export { useQuestionnaires, } from "./questionnaires"; export type { PlanQuestionnaireState, QuestionnaireEntry } from "./questionnaires"; +export { MAX_RESEARCH_BRIEF, ResearchLauncher } from "./research-launcher"; +export type { ResearchLaunchBlock, ResearchLaunchResult } from "./research-launcher"; export { SendAction } from "./send-action"; export { PlanStatus } from "./status"; export type { PlanStatusProps } from "./status"; export { ThreadObserver, ThreadStore, useThreads } from "./threads"; export type { Draft, ThreadState, ThreadView } from "./threads"; +export { + MENU_SURFACE, + ROW as MENU_ROW, + ROW_OFF as MENU_ROW_OFF, + ROW_ON as MENU_ROW_ON, +} from "./toolbar/surface"; export { presenceClass, transitionPresence, useTransitionPresence } from "./transition-presence"; export type { PresenceAction, PresencePhase, TransitionPresence } from "./transition-presence"; export type { Connection, Transport, Unsubscribe } from "./transport"; diff --git a/packages/editor/src/plan-editor.tsx b/packages/editor/src/plan-editor.tsx index 6ea4f33d7..2b4a6e346 100644 --- a/packages/editor/src/plan-editor.tsx +++ b/packages/editor/src/plan-editor.tsx @@ -33,6 +33,7 @@ import type { QuestionnaireStore } from "./questionnaires"; import type { ThreadStore } from "./threads"; import type { Connection, Transport } from "./transport"; import type { CommentPresentation, QuestionStepMotion, ResearchStore } from "./widget-options"; +import type { ResearchLauncher } from "./research-launcher"; /** * Lexical paints remote cursors with inline styles unless the theme names a @@ -83,6 +84,8 @@ export type PlanEditorProps = { evidence?: (questionnaireId: string) => ReactNode | null; /** Durable Research Workspace state and actions supplied by the host app. */ research?: ResearchStore; + /** Lets the host open the research composer at the end of the document. */ + researchLauncher?: ResearchLauncher; /** The same arrangement for comment threads. */ threads?: ThreadStore; /** Remembered by the document host while this surface is hidden. */ @@ -122,6 +125,7 @@ export function PlanEditor( evidence, readOnly, research, + researchLauncher, scrollTop, threads, user, @@ -315,6 +319,7 @@ export function PlanEditor( evidence, research, researchDrafts, + researchLauncher, threads, changes, wire, @@ -339,6 +344,7 @@ export function PlanEditor( evidence, research, researchDrafts, + researchLauncher, commentPresentation, motionImmediately, questionMotion, diff --git a/packages/editor/src/research-launcher.test.ts b/packages/editor/src/research-launcher.test.ts new file mode 100644 index 000000000..9499c3b93 --- /dev/null +++ b/packages/editor/src/research-launcher.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; + +import { MAX_RESEARCH_BRIEF, ResearchLauncher } from "./research-launcher"; + +import type { ResearchLaunchResult } from "./research-launcher"; + +function launch(result: ResearchLaunchResult = { ok: true }) { + let opened: string[] = []; + return { + opened, + check: () => result, + open: (brief: string) => { + opened.push(brief); + return result; + }, + reveal: () => true, + }; +} + +describe("research launcher", () => { + test("is unavailable until an editor attaches", () => { + let launcher = new ResearchLauncher(); + expect(launcher.check("brief")).toEqual({ ok: false, reason: "unavailable" }); + expect(launcher.open("brief")).toEqual({ ok: false, reason: "unavailable" }); + expect(launcher.reveal()).toBe(false); + }); + + test("opens through the attached editor", () => { + let launcher = new ResearchLauncher(); + let editor = launch(); + launcher.attach(editor); + expect(launcher.open("what changed?")).toEqual({ ok: true }); + expect(editor.opened).toEqual(["what changed?"]); + expect(launcher.reveal()).toBe(true); + }); + + test("a stale detach leaves the newer editor attached", () => { + let launcher = new ResearchLauncher(); + let detachFirst = launcher.attach(launch()); + let second = launch(); + let detachSecond = launcher.attach(second); + detachFirst(); + expect(launcher.open("x")).toEqual({ ok: true }); + expect(second.opened).toEqual(["x"]); + detachSecond(); + expect(launcher.open("x")).toEqual({ ok: false, reason: "unavailable" }); + }); + + test("reports the editor's reason and never opens past a failed check", () => { + let launcher = new ResearchLauncher(); + let editor = launch({ ok: false, reason: "drafting" }); + launcher.attach(editor); + expect(launcher.check("x")).toEqual({ ok: false, reason: "drafting" }); + expect(launcher.open("x")).toEqual({ ok: false, reason: "drafting" }); + expect(editor.opened).toEqual([]); + }); + + test("refuses a brief the composer could not send", () => { + let launcher = new ResearchLauncher(); + let editor = launch(); + launcher.attach(editor); + let longest = "a".repeat(MAX_RESEARCH_BRIEF); + expect(launcher.open(longest)).toEqual({ ok: true }); + expect(launcher.check(`${longest}a`)).toEqual({ ok: false, reason: "too-long" }); + expect(launcher.open(`${longest}a`)).toEqual({ ok: false, reason: "too-long" }); + expect(editor.opened).toEqual([longest]); + }); +}); diff --git a/packages/editor/src/research-launcher.ts b/packages/editor/src/research-launcher.ts new file mode 100644 index 000000000..4760db31c --- /dev/null +++ b/packages/editor/src/research-launcher.ts @@ -0,0 +1,54 @@ +/** Matches the research composer's own `maxLength`. */ +export const MAX_RESEARCH_BRIEF = 4096; + +export type ResearchLaunchBlock = + | "too-long" + | "drafting" + | "read-only" + | "disconnected" + | "unavailable"; + +export type ResearchLaunchResult = { ok: true } | { ok: false; reason: ResearchLaunchBlock }; + +type Launch = { + check: () => ResearchLaunchResult; + open: (brief: string) => ResearchLaunchResult; + reveal: () => boolean; +}; + +const UNAVAILABLE: ResearchLaunchResult = { ok: false, reason: "unavailable" }; + +/** + * Lets a host outside the editor open the document's research composer. + * + * The host owns this object; the mounted editor attaches the same draft path + * the `/` menu uses, so a brief started elsewhere is placed and started there. + */ +export class ResearchLauncher { + #launch: Launch | undefined; + + attach(launch: Launch): () => void { + this.#launch = launch; + return () => { + if (this.#launch === launch) this.#launch = undefined; + }; + } + + /** Why a brief could not open now, before the host changes anything. */ + check(brief: string): ResearchLaunchResult { + if (brief.length > MAX_RESEARCH_BRIEF) return { ok: false, reason: "too-long" }; + return this.#launch?.check() ?? UNAVAILABLE; + } + + /** Open the composer at the end of the document with `brief` filled in. */ + open(brief: string): ResearchLaunchResult { + let checked = this.check(brief); + if (!checked.ok) return checked; + return this.#launch?.open(brief) ?? UNAVAILABLE; + } + + /** Focus the research draft that is already open. */ + reveal(): boolean { + return this.#launch?.reveal() ?? false; + } +} diff --git a/packages/editor/src/toolbar/index.tsx b/packages/editor/src/toolbar/index.tsx index 8cd3cd1ed..d4be6bd16 100644 --- a/packages/editor/src/toolbar/index.tsx +++ b/packages/editor/src/toolbar/index.tsx @@ -16,6 +16,7 @@ import { widgets$ } from "../widgets-plugin"; import { SelectionBubble } from "./bubble"; import { LinkSurface } from "./link"; import { ResearchComposerSurface } from "./research"; +import { ResearchLaunchRegistration } from "./research-launch"; import { SlashMenu } from "./slash"; const RESEARCH_ACTIONS = new Set(["research"]); @@ -82,6 +83,16 @@ export function Toolbar() { research={options.research} /> )} + {options.research && options.researchDrafts && options.researchLauncher && ( + + )} ); } diff --git a/packages/editor/src/toolbar/research-launch.tsx b/packages/editor/src/toolbar/research-launch.tsx new file mode 100644 index 000000000..a476f2816 --- /dev/null +++ b/packages/editor/src/toolbar/research-launch.tsx @@ -0,0 +1,91 @@ +import { useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { $createParagraphNode, $getRoot, $isParagraphNode } from "lexical"; + +import { captureResearchPosition } from "./research"; + +import type { Binding } from "@lexical/yjs"; +import type { RelativePosition } from "yjs"; +import type { ResearchDraftStore } from "../research-draft"; +import type { ResearchLauncher, ResearchLaunchResult } from "../research-launcher"; + +const DRAFT_INPUT = '[role="region"][aria-label="Research question"] textarea'; + +/** Once the composer takes focus, put the caret after the prefilled brief. */ +function caretToEndOnFocus(): void { + let done = () => { + document.removeEventListener("focusin", focused, true); + clearTimeout(timer); + }; + let focused = (event: FocusEvent) => { + let target = event.target; + if (!(target instanceof HTMLTextAreaElement) || !target.matches(DRAFT_INPUT)) return; + done(); + target.setSelectionRange(target.value.length, target.value.length); + target.scrollTop = target.scrollHeight; + }; + let timer = setTimeout(done, 1_000); + document.addEventListener("focusin", focused, true); +} + +/** Attach the host's launcher to this editor: an empty last paragraph, then the usual draft. */ +export function ResearchLaunchRegistration( + { binding, canEdit, connected, disabled, drafts, launcher }: { + binding?: Binding; + canEdit?: boolean; + connected?: boolean; + disabled?: boolean; + drafts: ResearchDraftStore; + launcher: ResearchLauncher; + }, +) { + let [editor] = useLexicalComposerContext(); + useEffect(() => { + let check = (): ResearchLaunchResult => { + if (canEdit === false) return { ok: false, reason: "read-only" }; + if (!connected || !binding || disabled) return { ok: false, reason: "disconnected" }; + if (!drafts.canOpen()) return { ok: false, reason: "drafting" }; + return { ok: true }; + }; + return launcher.attach({ + check, + open: brief => { + let checked = check(); + if (!checked.ok || !binding) return checked; + let key: string | undefined; + editor.update(() => { + let root = $getRoot(); + let last = root.getLastChild(); + let target = $isParagraphNode(last) && last.getTextContentSize() === 0 + ? last + : $createParagraphNode(); + if (target !== last) root.append(target); + target.select(); + key = target.getKey(); + }, { discrete: true }); + let element = key ? editor.getElementByKey(key) : null; + if (!element) return { ok: false, reason: "unavailable" }; + // Centre it so the composer below the paragraph has room to show. + element.scrollIntoView({ block: "center" }); + let position: RelativePosition | undefined; + editor.getEditorState().read(() => { + position = captureResearchPosition(binding); + }); + if (brief) caretToEndOnFocus(); + if (!drafts.open(element.getBoundingClientRect(), position)) { + return { ok: false, reason: "drafting" }; + } + if (brief) drafts.change(brief); + return { ok: true }; + }, + reveal: () => { + let input = editor.getRootElement()?.closest(".mdxeditor")?.querySelector( + DRAFT_INPUT, + ); + input?.focus(); + return !!input; + }, + }); + }, [binding, canEdit, connected, disabled, drafts, editor, launcher]); + return null; +} diff --git a/packages/editor/src/toolbar/surface.ts b/packages/editor/src/toolbar/surface.ts index 7008c278b..74185e96c 100644 --- a/packages/editor/src/toolbar/surface.ts +++ b/packages/editor/src/toolbar/surface.ts @@ -32,8 +32,12 @@ export function listenToEditorGeometry(editor: LexicalEditor, listener: () => vo }); } -export const SHELL = "fixed z-50 rounded-lg bg-page p-1 ring-hairline shadow-raised"; +/** The `/` menu's surface and row, for hosts that position a matching menu themselves. */ +export const MENU_SURFACE = "rounded-lg bg-page p-1 ring-hairline shadow-raised"; +export const SHELL = `fixed z-50 ${MENU_SURFACE}`; export const ROW = "plan-menu-row flex h-8 w-full items-center rounded-sm px-2 text-left text-sm"; +export const ROW_ON = "bg-selected text-text-primary"; +export const ROW_OFF = "text-text-tertiary"; export const CELL = "plan-menu-cell inline-flex size-7 shrink-0 items-center justify-center rounded-sm text-sm font-semibold"; export const CELL_ON = "bg-selected text-text-primary"; diff --git a/packages/editor/src/widget-options.ts b/packages/editor/src/widget-options.ts index 70e374692..673432055 100644 --- a/packages/editor/src/widget-options.ts +++ b/packages/editor/src/widget-options.ts @@ -1,5 +1,6 @@ import { Cell } from "@mdxeditor/gurx"; +import type { ResearchLauncher } from "./research-launcher"; import type { ReactNode } from "react"; import type { CardMetaStore } from "./card-meta"; import type { Binding } from "@lexical/yjs"; @@ -45,6 +46,7 @@ export type WidgetOptions = { evidence?: (questionnaireId: string) => ReactNode | null; research?: ResearchStore; researchDrafts?: ResearchDraftStore; + researchLauncher?: ResearchLauncher; threads?: ThreadStore; changes?: ChangeStore; wire?: Transport; diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index 93280cd29..fbea6ece9 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -340,7 +340,7 @@ 2 ] ], - "sourceHash": "ad4d0ebbe426db91b3b439ee3c026a6bee9220f5224b5182c14c93cfc5765f8b" + "sourceHash": "c939b23c2f50d9a1ac3607af544e93efce6e34be2de43c41ea1cddb4dc62ad49" }, { "file": "packages/editor/src/collaboration.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index e43343c7a..7cf8c4704 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -1,4 +1,45 @@ [ + { + "file": "apps/web/src/chat/command-picker.tsx", + "reason": "Chat reuses the editor slash-menu class recipes; the imported constants are static token-based utilities in the reviewed editor producer.", + "cases": [ + [ + "dynamic", + "class", + "MENU_SURFACE", + "class > CommandPicker >
", + 1 + ], + [ + "dynamic", + "class", + "MENU_ROW", + "class > CommandPicker >