From 47fd63a4aae0a3b37a1773f7707db67a43d9e05b Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:02:37 +0100 Subject: [PATCH 1/5] Point /research in Chat to the document instead of posting it Typing / at the start of the Chat composer opens a command menu in the document slash menu's recipe, offering Research. A /research draft is never sent; an inline hint points to the document, and Go to document puts the caret at the end of the document. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/chat/chat.tsx | 124 ++++++++++++++++++++++++--- apps/web/src/chat/command-picker.tsx | 50 +++++++++++ apps/web/src/chat/commands.test.ts | 46 ++++++++++ apps/web/src/chat/commands.ts | 81 +++++++++++++++++ apps/web/src/document-end.ts | 29 +++++++ apps/web/src/room-workspace.tsx | 15 ++++ e2e/chat-commands.e2e.ts | 75 ++++++++++++++++ 7 files changed, 409 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/chat/command-picker.tsx create mode 100644 apps/web/src/chat/commands.test.ts create mode 100644 apps/web/src/chat/commands.ts create mode 100644 apps/web/src/document-end.ts create mode 100644 e2e/chat-commands.e2e.ts diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index f1cf6f93f..d670504a9 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -17,6 +17,16 @@ import type { DraftInputHandle } from "./draft-input"; import { ModeSwitch } from "./mode-switch"; import "./composer.css"; +import { CommandPicker } from "./command-picker"; +import { + CHAT_COMMANDS, + commandKeyAction, + commandText, + commandTrigger, + commandTriggerKey, + draftCommand, + filterCommands, +} from "./commands"; import { MentionPicker } from "./mention-picker"; import { chatAuthors, @@ -62,6 +72,7 @@ import plannerResume from "../assets/icons/planner-resume.svg"; import type { Chat as Wire, ConversationPlan } from "@chopin/protocol"; import type { Repository } from "../api"; +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 +108,10 @@ export type ChatProps = { sourceDestination?: ChatDestination; /** Opens Decisions, where a waiting workflow's questions are. */ onShowDecisions?: () => void; + /** Whether this document can start research, which happens in the document itself. */ + research?: boolean; + /** Puts the caret at the end of the document. */ + onOpenDocument?: () => 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 +149,8 @@ export function Chat( notice, emptyNotice, onShowDecisions, + research = false, + onOpenDocument, referencesEnabled, repository, room, @@ -157,6 +174,7 @@ export function Chat( 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 +185,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 +246,25 @@ 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 && research ? 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; + let pendingCommand = 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 @@ -326,7 +355,7 @@ export function Chat( let submit = () => { if (submission.current || !composerReady || !wire) return; let current = draftRef.current; - if (!current.text.trim()) return; + if (!current.text.trim() || draftCommand(current.text)) return; let submitted = prepareDraftSubmission(current); let prefix = effectiveMode && !addressedOutsideReferences(submitted.text, submitted.references) ? `${MENTION} ` @@ -453,6 +482,25 @@ export function Chat( let moveMention = (to: (index: number) => number) => setMentionCursor({ key: mentionKey, index: to(mentionActive) }); + let chooseCommand = (choice: ChatCommand) => { + let next = commandText(choice); + setDraft(current => reviseComposerDraft(current, next, [])); + setSelection({ start: next.length, end: next.length }); + setSendError(undefined); + setDismissedPicker(undefined); + pendingCaret.current = next.length; + }; + + let openDocument = () => { + let current = draftRef.current; + // A bare command has nothing worth keeping; a typed brief stays for copying. + if (current.text.trim().toLowerCase() === "/research") { + setDraft(reviseComposerDraft(current, "", [])); + setSelection({ start: 0, end: 0 }); + } + onOpenDocument?.(); + }; + let transcriptView = ( )} - {!readonly && !archived && (sendError || !composerReady || !agent) && ( + {!readonly && !archived && (sendError || !composerReady || pendingCommand || !agent) && (
{sendError ?? (!composerReady ? connected ? "Synchronizing…" : connectionLost ? "Connection lost" : "Connecting…" + : pendingCommand + ? research + ? "Use /research in the document" + : "Research isn’t available in this document" : "Chopin unavailable")} {sendError @@ -544,13 +596,19 @@ export function Chat( Retry ) - : !composerReady && wire && ( + : !composerReady + ? wire && ( + ) + : pendingCommand && research && onOpenDocument && ( + )}
)} @@ -571,6 +629,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 +886,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..199b5f7e8 --- /dev/null +++ b/apps/web/src/chat/command-picker.tsx @@ -0,0 +1,50 @@ +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..6ae155ed2 --- /dev/null +++ b/apps/web/src/chat/commands.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; + +import { + CHAT_COMMANDS, + 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(commandText(CHAT_COMMANDS[0]!)).toBe("/research "); + }); + + 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..ab84265eb --- /dev/null +++ b/apps/web/src/chat/commands.ts @@ -0,0 +1,81 @@ +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 starts in the document, + * where its request card is placed, so Chat points there 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))); +} + +/** The command a draft starts with, which must never be posted as a message. */ +export function draftCommand(text: string): ChatCommand["id"] | undefined { + return /^\s*\/research(?=\s|$)/i.test(text) ? "research" : undefined; +} + +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/document-end.ts b/apps/web/src/document-end.ts new file mode 100644 index 000000000..8d51399d0 --- /dev/null +++ b/apps/web/src/document-end.ts @@ -0,0 +1,29 @@ +/** + * Put the caret after the last editable text in a document editor. + * + * The editor reads the browser selection on `selectionchange`, so a DOM range + * is enough; decorator blocks such as cards are skipped because they cannot + * hold a caret. + */ +export function focusDocumentEnd(root: HTMLElement): boolean { + let block = root.lastElementChild; + while (block && block.closest('[contenteditable="false"]') !== null) { + block = block.previousElementSibling; + } + if (!block) return false; + let walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + let text: Text | undefined; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (!node.parentElement?.closest('[contenteditable="false"]')) text = node as Text; + } + let range = document.createRange(); + if (text) range.setStart(text, text.length); + else range.setStart(block, 0); + range.collapse(true); + root.focus({ preventScroll: true }); + let selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + block.scrollIntoView({ block: "nearest" }); + return true; +} diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 06c9e1430..17d35c2c1 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -44,6 +44,7 @@ import { useNavigationDocument } from "./navigation-shell"; import { titleEdits } from "./title-edit"; import { peopleHere } from "./presence"; import { ResearchRequestStore } from "./research-requests"; +import { focusDocumentEnd } from "./document-end"; import { Wire } from "./wire"; import { useWorkspaceIds, useWorkspaceLayout, useWorkspaceState, Workspace } from "./workspace"; import { initialDocumentView, presentWorkspace, workspaceProfile } from "./workspace-model"; @@ -603,6 +604,18 @@ export function RoomWorkspace( }); }; + let showDocumentEnd = () => { + selectDestination("plan"); + requestAnimationFrame(() => { + let root = document.querySelector( + `[data-workspace-room="${ + CSS.escape(room) + }"] [data-document-view="plan"] .plan-content[contenteditable="true"]`, + ); + if (root) focusDocumentEnd(root); + }); + }; + useEffect(() => { let editable = canEdit && !archivedAt; latestCanEdit.current = editable; @@ -820,6 +833,8 @@ export function RoomWorkspace( ? "Discuss this report here. Messages stay with the report." : undefined} onShowDecisions={() => selectDestination("decisions")} + onOpenDocument={showDocumentEnd} + research={researchEnabled} people={peopleHere(members)} conversationPlan={conversation.state} conversationPlanJobs={conversation.jobs} diff --git a/e2e/chat-commands.e2e.ts b/e2e/chat-commands.e2e.ts new file mode 100644 index 000000000..83118d1a6 --- /dev/null +++ b/e2e/chat-commands.e2e.ts @@ -0,0 +1,75 @@ +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" }); +} + +test("/ in Chat offers Research, which points to the document 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 hint = chat.getByRole("status").filter({ hasText: "Use /research in the document" }); + + 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, "/"); + + await page.keyboard.type("res"); + await expect(menu).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(menu).toHaveCount(0); + await expectChatValue(draft, "/research "); + await expect(hint).toBeVisible(); + await expect(chat.getByRole("button", { name: "Send message" })).toBeDisabled(); + + // A typed brief is never posted as a literal message either. + await page.keyboard.type("what changed?"); + await page.keyboard.press("Enter"); + await expect(hint).toBeVisible(); + await expectChatValue(draft, "/research what changed?"); + + // No command menu for a path or a later slash. + await draft.fill("/usr/bin"); + await expect(menu).toHaveCount(0); + await expect(hint).toHaveCount(0); + + await draft.fill("/research"); + await chat.getByRole("button", { name: "Go to document" }).click(); + await expectChatValue(draft, ""); + let editor = content(page); + await expect(editor).toBeFocused(); + await page.keyboard.type("!"); + await expect(editor.getByText("Some prose.!", { exact: true })).toBeVisible(); + await page.keyboard.press("Enter"); + await page.keyboard.type("/research"); + await page.keyboard.press("Tab"); + await expect( + page.getByRole("region", { name: "Research question", exact: true }) + .getByRole("textbox", { name: "Research question", exact: true }), + ).toBeFocused(); + + expect(sent).toEqual([]); + await expect(chat.getByText("/research", { exact: true })).toHaveCount(0); +}); From a3a49f6cd762d763aae9c496228d9bc33a152e57 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:46:57 +0100 Subject: [PATCH 2/5] Open the document research composer from Chat /research Choosing Research in Chat, or sending a /research brief, now switches to the document and opens the existing research composer after the last paragraph with the brief filled in. The editor exposes this through a host-owned ResearchLauncher that attaches the same ResearchDraftStore path the / menu uses. Chat reuses the / menu's exported surface and row classes. The notice remains only where research is unavailable. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/chat/chat.tsx | 81 +++++++++---------- apps/web/src/chat/command-picker.tsx | 8 +- apps/web/src/chat/commands.ts | 6 +- apps/web/src/document-end.ts | 29 ------- apps/web/src/room-workspace.tsx | 21 +++-- e2e/chat-commands.e2e.ts | 55 ++++++------- packages/editor/src/index.ts | 7 ++ packages/editor/src/plan-editor.tsx | 6 ++ packages/editor/src/research-launcher.ts | 31 +++++++ packages/editor/src/toolbar/index.tsx | 9 +++ .../editor/src/toolbar/research-launch.tsx | 52 ++++++++++++ packages/editor/src/toolbar/surface.ts | 6 +- packages/editor/src/widget-options.ts | 2 + 13 files changed, 193 insertions(+), 120 deletions(-) delete mode 100644 apps/web/src/document-end.ts create mode 100644 packages/editor/src/research-launcher.ts create mode 100644 packages/editor/src/toolbar/research-launch.tsx diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index d670504a9..f9d617a64 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -108,10 +108,11 @@ export type ChatProps = { sourceDestination?: ChatDestination; /** Opens Decisions, where a waiting workflow's questions are. */ onShowDecisions?: () => void; - /** Whether this document can start research, which happens in the document itself. */ - research?: boolean; - /** Puts the caret at the end of the document. */ - onOpenDocument?: () => void; + /** + * Opens the document's research composer with this brief, when this + * document offers research. False when it cannot open now. + */ + onResearch?: (brief: string) => boolean; /** 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. */ @@ -149,8 +150,7 @@ export function Chat( notice, emptyNotice, onShowDecisions, - research = false, - onOpenDocument, + onResearch, referencesEnabled, repository, room, @@ -250,12 +250,13 @@ export function Chat( ? commandTrigger(draft.text, selection.start, selection.end) : undefined; let commandKey = command ? commandTriggerKey(command) : undefined; - let commandOptions = command && research ? filterCommands(CHAT_COMMANDS, command.query) : []; + 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; - let pendingCommand = draftCommand(draft.text); + // 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( commandOpen || mentionOpen || pickerOpen, @@ -352,10 +353,33 @@ export function Chat( setHistoryKey(current => current + 1); }; + let launchResearch = (brief: string, text: string) => { + let current = draftRef.current; + if (onResearch?.(brief)) { + setDraft(reviseComposerDraft(current, "", [])); + setSelection({ start: 0, end: 0 }); + setSendError(undefined); + setDismissedPicker(undefined); + return; + } + // Keep the command in the draft so Retry can open it again. + setDraft(reviseComposerDraft(current, text, [])); + setSelection({ start: text.length, end: text.length }); + setDismissedPicker(undefined); + pendingCaret.current = text.length; + setSendError("Research couldn’t open in the document."); + }; + let submit = () => { if (submission.current || !composerReady || !wire) return; let current = draftRef.current; - if (!current.text.trim() || draftCommand(current.text)) return; + if (draftCommand(current.text)) { + if (onResearch) { + launchResearch(current.text.replace(/^\s*\/research\s*/i, ""), current.text); + } + return; + } + if (!current.text.trim()) return; let submitted = prepareDraftSubmission(current); let prefix = effectiveMode && !addressedOutsideReferences(submitted.text, submitted.references) ? `${MENTION} ` @@ -482,24 +506,7 @@ export function Chat( let moveMention = (to: (index: number) => number) => setMentionCursor({ key: mentionKey, index: to(mentionActive) }); - let chooseCommand = (choice: ChatCommand) => { - let next = commandText(choice); - setDraft(current => reviseComposerDraft(current, next, [])); - setSelection({ start: next.length, end: next.length }); - setSendError(undefined); - setDismissedPicker(undefined); - pendingCaret.current = next.length; - }; - - let openDocument = () => { - let current = draftRef.current; - // A bare command has nothing worth keeping; a typed brief stays for copying. - if (current.text.trim().toLowerCase() === "/research") { - setDraft(reviseComposerDraft(current, "", [])); - setSelection({ start: 0, end: 0 }); - } - onOpenDocument?.(); - }; + let chooseCommand = (choice: ChatCommand) => launchResearch("", commandText(choice)); let transcriptView = ( )} - {!readonly && !archived && (sendError || !composerReady || pendingCommand || !agent) && ( + {!readonly && !archived && (sendError || !composerReady || blockedCommand || !agent) && (
{sendError ?? (!composerReady ? connected ? "Synchronizing…" : connectionLost ? "Connection lost" : "Connecting…" - : pendingCommand - ? research - ? "Use /research in the document" - : "Research isn’t available in this document" + : blockedCommand + ? "Research isn’t available in this document" : "Chopin unavailable")} {sendError @@ -596,19 +601,13 @@ export function Chat( Retry ) - : !composerReady - ? wire && ( + : !composerReady && wire && ( - ) - : pendingCommand && research && onOpenDocument && ( - )}
)} @@ -680,7 +679,7 @@ export function Chat( : undefined} aria-describedby={[ referencesEnabled ? instructionsId : undefined, - sendError || !composerReady || pendingCommand || !agent ? cueId : undefined, + sendError || !composerReady || blockedCommand || !agent ? cueId : undefined, ].filter(Boolean).join(" ") || undefined} aria-disabled={!composerReady || submitting} aria-invalid={!!sendError || undefined} @@ -887,7 +886,7 @@ export function Chat( diff --git a/apps/web/src/chat/command-picker.tsx b/apps/web/src/chat/command-picker.tsx index 199b5f7e8..e53209965 100644 --- a/apps/web/src/chat/command-picker.tsx +++ b/apps/web/src/chat/command-picker.tsx @@ -1,3 +1,5 @@ +import { MENU_ROW, MENU_ROW_OFF, MENU_ROW_ON, MENU_SURFACE } from "@chopin/editor"; + import { referenceOptionId } from "./reference-picker"; import type { ChatCommand } from "./commands"; @@ -20,7 +22,7 @@ export function CommandPicker( ) { return (
@@ -28,9 +30,7 @@ export function CommandPicker( {options.map((option, index) => ( ) - : !composerReady && wire && ( + : !composerReady + ? wire && ( + ) + : researchBlock === "drafting" + ? onShowResearch && ( + + ) + : (researchBlock === "disconnected" || researchBlock === "unavailable") && ( + )}
)} @@ -679,7 +717,9 @@ export function Chat( : undefined} aria-describedby={[ referencesEnabled ? instructionsId : undefined, - sendError || !composerReady || blockedCommand || !agent ? cueId : undefined, + sendError || !composerReady || researchBlock || blockedCommand || !agent + ? cueId + : undefined, ].filter(Boolean).join(" ") || undefined} aria-disabled={!composerReady || submitting} aria-invalid={!!sendError || undefined} @@ -707,6 +747,7 @@ export function Chat( ) ); setSendError(undefined); + setResearchBlock(undefined); setDismissedPicker(undefined); setSelection({ start: event.currentTarget.selectionStart, diff --git a/apps/web/src/chat/commands.test.ts b/apps/web/src/chat/commands.test.ts index 6ae155ed2..c7460db9d 100644 --- a/apps/web/src/chat/commands.test.ts +++ b/apps/web/src/chat/commands.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { CHAT_COMMANDS, + commandBrief, commandKeyAction, commandText, commandTrigger, @@ -33,9 +34,19 @@ describe("chat commands", () => { 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(); diff --git a/apps/web/src/chat/commands.ts b/apps/web/src/chat/commands.ts index e07f672f0..97f9b04dc 100644 --- a/apps/web/src/chat/commands.ts +++ b/apps/web/src/chat/commands.ts @@ -45,9 +45,19 @@ export function filterCommands( return commands.filter(command => command.keywords.some(keyword => keyword.startsWith(prefix))); } -/** The command a draft starts with, which must never be posted as a message. */ +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 /^\s*\/research(?=\s|$)/i.test(text) ? "research" : 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 { diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 08198dc8f..c99018f30 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -50,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"; @@ -605,12 +610,17 @@ export function RoomWorkspace( }); }; - let startResearch = (brief: string) => { - if (!researchLauncher.available()) return false; + 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. - requestAnimationFrame(() => researchLauncher.open(brief)); - return true; + await new Promise(resolve => requestAnimationFrame(resolve)); + return researchLauncher.open(brief); + }; + let showResearchDraft = () => { + selectDestination("plan"); + requestAnimationFrame(() => researchLauncher.reveal()); }; useEffect(() => { @@ -831,6 +841,7 @@ export function RoomWorkspace( : undefined} onShowDecisions={() => selectDestination("decisions")} onResearch={researchEnabled ? startResearch : undefined} + onShowResearch={showResearchDraft} people={peopleHere(members)} conversationPlan={conversation.state} conversationPlanJobs={conversation.jobs} diff --git a/e2e/chat-commands.e2e.ts b/e2e/chat-commands.e2e.ts index ff0a4d104..4b5c3a7f0 100644 --- a/e2e/chat-commands.e2e.ts +++ b/e2e/chat-commands.e2e.ts @@ -64,7 +64,32 @@ test("/research in Chat opens the document's research composer instead of postin 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.getByText("/research", { exact: false })).toHaveCount(0); + 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 d4942692c..18a601b69 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -41,7 +41,8 @@ export { useQuestionnaires, } from "./questionnaires"; export type { PlanQuestionnaireState, QuestionnaireEntry } from "./questionnaires"; -export { ResearchLauncher } from "./research-launcher"; +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"; 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 index acc20e5e5..4760db31c 100644 --- a/packages/editor/src/research-launcher.ts +++ b/packages/editor/src/research-launcher.ts @@ -1,8 +1,23 @@ +/** 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 = { - available: () => boolean; - open: (brief: string) => boolean; + 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. * @@ -19,13 +34,21 @@ export class ResearchLauncher { }; } - /** Whether a draft could open now: an editable, connected editor with no draft already open. */ - available(): boolean { - return this.#launch?.available() ?? false; + /** 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): boolean { - return this.#launch?.open(brief) ?? false; + 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 964c839c1..d4be6bd16 100644 --- a/packages/editor/src/toolbar/index.tsx +++ b/packages/editor/src/toolbar/index.tsx @@ -86,6 +86,8 @@ export function Toolbar() { {options.research && options.researchDrafts && options.researchLauncher && ( { + 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, disabled, drafts, launcher }: { + { binding, canEdit, connected, disabled, drafts, launcher }: { binding?: Binding; + canEdit?: boolean; + connected?: boolean; disabled?: boolean; drafts: ResearchDraftStore; launcher: ResearchLauncher; }, ) { let [editor] = useLexicalComposerContext(); - useEffect(() => - launcher.attach({ - available: () => !disabled && !!binding && drafts.canOpen(), + 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 => { - if (disabled || !binding || !drafts.canOpen()) return false; + let checked = check(); + if (!checked.ok || !binding) return checked; let key: string | undefined; editor.update(() => { let root = $getRoot(); @@ -36,17 +64,28 @@ export function ResearchLaunchRegistration( key = target.getKey(); }, { discrete: true }); let element = key ? editor.getElementByKey(key) : null; - if (!element) return false; + 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 (!drafts.open(element.getBoundingClientRect(), position)) return false; + if (brief) caretToEndOnFocus(); + if (!drafts.open(element.getBoundingClientRect(), position)) { + return { ok: false, reason: "drafting" }; + } if (brief) drafts.change(brief); - return true; + return { ok: true }; + }, + reveal: () => { + let input = editor.getRootElement()?.closest(".mdxeditor")?.querySelector( + DRAFT_INPUT, + ); + input?.focus(); + return !!input; }, - }), [binding, disabled, drafts, editor, launcher]); + }); + }, [binding, canEdit, connected, disabled, drafts, editor, launcher]); return null; } From 117cb39a90293eaeba00e179b4c53a40315bd1c1 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:16:47 +0100 Subject: [PATCH 5/5] [ci-fix] renew editor design review after reconnect changes --- scripts/design-contract/exceptions/dynamic-editor.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",