Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 153 additions & 11 deletions apps/web/src/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,25 @@

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";
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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<ResearchLaunchResult>;
/** 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. */
Expand Down Expand Up @@ -134,6 +154,8 @@ export function Chat(
notice,
emptyNotice,
onShowDecisions,
onResearch,
onShowResearch,
referencesEnabled,
repository,
room,
Expand All @@ -154,9 +176,12 @@ export function Chat(
});
let [submitting, setSubmitting] = useState(false);
let [sendError, setSendError] = useState<string>();
let [researchBlock, setResearchBlock] = useState<ResearchLaunchBlock>();
let researching = useRef(false);
let [selection, setSelection] = useState({ start: 0, end: 0 });
let [dismissedPicker, setDismissedPicker] = useState<string>();
let [mentionCursor, setMentionCursor] = useState<{ key?: string; index: number }>({ index: 0 });
let [commandCursor, setCommandCursor] = useState<{ key?: string; index: number }>({ index: 0 });
let textarea = useRef<DraftInputHandle>(null);
let composerRoot = useRef<HTMLDivElement>(null);
let [mode, setMode] = useState(false);
Expand All @@ -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<Socket | undefined>(undefined);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<ResearchLaunchBlock, string> = {
"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 = (
<Transcript
active={active}
Expand Down Expand Up @@ -514,7 +592,8 @@ export function Chat(
Type # to reference a document.
</p>
)}
{!readonly && !archived && (sendError || !composerReady || !agent) && (
{!readonly && !archived
&& (sendError || !composerReady || researchBlock || blockedCommand || !agent) && (
<div
className={sendError ? "composer-notice motion-feedback" : "composer-notice"}
data-motion-feedback={sendError ? "alert" : undefined}
Expand All @@ -532,6 +611,10 @@ export function Chat(
<span>
{sendError ?? (!composerReady
? connected ? "Synchronizing…" : connectionLost ? "Connection lost" : "Connecting…"
: researchBlock
? researchCopy[researchBlock]
: blockedCommand
? "Research isn’t available in this document"
: "Chopin unavailable")}
</span>
{sendError
Expand All @@ -544,13 +627,25 @@ export function Chat(
Retry
</button>
)
: !composerReady && wire && (
: !composerReady
? wire && (
<button
className="btn btn-sm btn-ghost"
onClick={() => wire.reconnect()}
>
{connectionLost ? "Reconnect" : "Retry"}
</button>
)
: researchBlock === "drafting"
? onShowResearch && (
<button className="btn btn-sm btn-ghost" onClick={onShowResearch} type="button">
Go to it
</button>
)
: (researchBlock === "disconnected" || researchBlock === "unavailable") && (
<button className="btn btn-sm btn-ghost" onClick={submit} type="button">
Retry
</button>
)}
</div>
)}
Expand All @@ -571,6 +666,15 @@ export function Chat(
: picker}
/>
)}
{commandOpen && (
<CommandPicker
active={commandActive}
id={commandPickerId}
onActive={index => setCommandCursor({ key: commandKey, index })}
onSelect={chooseCommand}
options={commandOptions}
/>
)}
{mentionOpen && (
<MentionPicker
active={mentionActive}
Expand All @@ -596,20 +700,30 @@ export function Chat(
<>
<DraftInput
aria-label="Message"
aria-activedescendant={mentionOpen
aria-activedescendant={commandOpen
? referenceOptionId(commandPickerId, commandActive)
: mentionOpen
? referenceOptionId(mentionPickerId, mentionActive)
: pickerOpen && activeOption
? referenceOptionId(pickerId, picker.options.indexOf(activeOption))
: undefined}
aria-autocomplete="list"
aria-controls={mentionOpen ? mentionPickerId : pickerOpen ? pickerId : undefined}
aria-controls={commandOpen
? commandPickerId
: mentionOpen
? mentionPickerId
: pickerOpen
? pickerId
: undefined}
aria-describedby={[
referencesEnabled ? instructionsId : undefined,
sendError || !composerReady || !agent ? cueId : undefined,
sendError || !composerReady || researchBlock || blockedCommand || !agent
? cueId
: undefined,
].filter(Boolean).join(" ") || undefined}
aria-disabled={!composerReady || submitting}
aria-invalid={!!sendError || undefined}
aria-expanded={pickerOpen || mentionOpen}
aria-expanded={commandOpen || pickerOpen || mentionOpen}
readOnly={!composerReady || submitting}
role="combobox"
resetKey={historyKey}
Expand All @@ -633,6 +747,7 @@ export function Chat(
)
);
setSendError(undefined);
setResearchBlock(undefined);
setDismissedPicker(undefined);
setSelection({
start: event.currentTarget.selectionStart,
Expand All @@ -641,6 +756,32 @@ export function Chat(
}}
onKeyDown={event => {
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;
Expand Down Expand Up @@ -785,7 +926,8 @@ export function Chat(
</span>
<SendAction
busy={submitting}
disabled={!composerReady || submitting || !draft.text.trim()}
disabled={!composerReady || submitting || !draft.text.trim()
|| blockedCommand}
onClick={submit}
label="Send message"
/>
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/chat/command-picker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`absolute bottom-full left-2.5 z-30 mb-1 w-56 max-w-[calc(100%-1.25rem)] ${MENU_SURFACE}`}
data-chat-command-picker=""
data-focus-boundary=""
>
<div aria-label="Commands" id={id} role="listbox">
{options.map((option, index) => (
<button
aria-selected={index === active}
className={`${MENU_ROW} ${index === active ? MENU_ROW_ON : MENU_ROW_OFF}`}
data-press="wide"
id={referenceOptionId(id, index)}
key={option.id}
onClick={() => onSelect(option)}
onMouseDown={event => event.preventDefault()}
onMouseEnter={() => onActive(index)}
role="option"
tabIndex={-1}
type="button"
>
{option.label}
</button>
))}
</div>
</div>
);
}
Loading
Loading