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
180 changes: 141 additions & 39 deletions apps/web/src/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@

import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react";

import { MAX_RESEARCH_BRIEF, SendAction, usePopoverDismissal } from "@chopin/editor";
import {
CONNECTION_GRACE,
MAX_RESEARCH_BRIEF,
SendAction,
useConnectionNotice,
usePopoverDismissal,
} from "@chopin/editor";
import { MENTION } from "@chopin/protocol/address";
import { ArchiveIcon, InfoIcon, LoaderIcon, LockIcon, PlusIcon, WarningIcon } from "@chopin/icons";
import { ArchiveIcon, InfoIcon, LockIcon, PlusIcon, WarningIcon } from "@chopin/icons";
import { DraftInput } from "./draft-input";
import type { DraftInputHandle } from "./draft-input";
import { ModeSwitch } from "./mode-switch";
Expand Down Expand Up @@ -132,6 +138,44 @@ export function runCounts(runs: Wire.Runs | undefined): { active: number; paused
};
}

function draftKey(room: string): string {
return `chopin:chat-draft:${room}`;
}

/** Holds an unsent message across a reload of this tab; storage may refuse. */
function keepDraft(room: string, draft: ComposerDraft): void {
try {
if (!draft.text.trim()) return sessionStorage.removeItem(draftKey(room));
sessionStorage.setItem(
draftKey(room),
JSON.stringify({ text: draft.text, references: draft.references }),
);
} catch {
// Private windows and blocked storage: the draft is lost as it was before.
}
}

/** The message a reload interrupted; anything malformed starts empty. */
function restoreDraft(room: string): ComposerDraft {
let empty = { text: "", references: [] };
try {
let raw = sessionStorage.getItem(draftKey(room));
if (!raw) return empty;
let saved = JSON.parse(raw) as Partial<ComposerDraft>;
if (typeof saved.text !== "string") return empty;
let references = Array.isArray(saved.references)
&& saved.references.every(reference =>
typeof reference?.start === "number" && typeof reference.end === "number"
&& reference.end <= saved.text!.length
)
? saved.references
: [];
return { text: saved.text, references };
} catch {
return empty;
}
}

export function Chat(
{
active = true,
Expand Down Expand Up @@ -170,11 +214,11 @@ export function Chat(
let [busy, setBusy] = useState(false);
let [runs, setRuns] = useState<Wire.Runs>();
let counts = runCounts(runs);
let [draft, setDraft] = useState<ComposerDraft>({
text: "",
references: [],
});
let [draft, setDraft] = useState<ComposerDraft>(() => restoreDraft(room));
let [submitting, setSubmitting] = useState(false);
// A send pressed during a blip, made once the connection is back.
let [held, setHeld] = useState(false);
let sending = submitting || held;
let [sendError, setSendError] = useState<string>();
let [researchBlock, setResearchBlock] = useState<ResearchLaunchBlock>();
let researching = useRef(false);
Expand All @@ -190,11 +234,16 @@ export function Chat(
let submission = useRef<object | undefined>(undefined);
let draftRef = useRef(draft);
draftRef.current = draft;
// A reload, including the header's fallback when reconnecting keeps
// failing, must not take an unsent message with it. Kept as it changes
// rather than on unload, which a browser does not promise to announce.
useEffect(() => keepDraft(room, draft), [room, draft]);
let pickerId = useId();
let mentionPickerId = useId();
let commandPickerId = useId();
let instructionsId = useId();
let cueId = useId();
let connectionId = useId();
let synchronized = useRef<Socket | undefined>(undefined);
let activity = useRef(onActivity);
let reportedBusy = useRef(false);
Expand All @@ -204,7 +253,16 @@ export function Chat(
if (!connected) synchronized.current = undefined;
let transcriptReady = connected && synchronized.current === wire;
let composerReady = transcriptReady && !readonly && !archived;
let connectionLost = !connected && ["reconnecting", "closed"].includes(wire?.status ?? "");
// A draft never waits on the connection; only sending does.
let draftable = !readonly && !archived;
let connectionNotice = useConnectionNotice(!connected);
let connectionLabel = connectionNotice === "none"
? undefined
: connectionNotice === "offline" || wire?.status === "closed" || wire?.status === "denied"
? "Offline"
: wire?.status === "connecting"
? "Connecting…"
: "Reconnecting…";
let effectiveMode = agent && (mode || addressedOutsideReferences(draft.text, draft.references));
let workingTurn = transcriptReady ? turn : undefined;
let suspendedWork = !transcriptReady && turn && transcript.activeAnchorId
Expand Down Expand Up @@ -387,14 +445,20 @@ export function Chat(
};

let submit = () => {
if (submission.current || !composerReady || !wire) return;
if (submission.current || !draftable) 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;
if (!composerReady || !wire?.connected) {
// Inside the grace period nothing says the connection is down, so
// the send waits for it rather than being refused.
if (connectionNotice === "none") setHeld(true);
return;
}
let submitted = prepareDraftSubmission(current);
let prefix = effectiveMode && !addressedOutsideReferences(submitted.text, submitted.references)
? `${MENTION} `
Expand Down Expand Up @@ -449,8 +513,38 @@ export function Chat(
});
};

// Stop and Resume pressed during a blip are sent once it is over, or never.
let heldControl = useRef<"chat:abort" | "chat:resume">(undefined);
let control = (kind: "chat:abort" | "chat:resume") => {
if (wire?.connected) wire.send(kind);
else heldControl.current = kind;
};
useEffect(() => {
if (connectionNotice !== "none") heldControl.current = undefined;
else if (connected && heldControl.current && wire?.connected) {
wire.send(heldControl.current);
heldControl.current = undefined;
}
});

// Never held longer than the grace period, however the wait ends.
useEffect(() => {
if (!held) return;
let timer = setTimeout(() => setHeld(false), CONNECTION_GRACE);
return () => clearTimeout(timer);
}, [held]);
useEffect(() => {
if (!held) return;
if (connectionNotice !== "none") {
setHeld(false);
} else if (composerReady && wire?.connected) {
setHeld(false);
submit();
}
});

let toggleMode = () => {
if (!composerReady || submitting || !agent) return;
if (!draftable || sending || !agent) return;
let current = draftRef.current;
let at = selection.start;
let end = selection.end;
Expand Down Expand Up @@ -593,7 +687,7 @@ export function Chat(
</p>
)}
{!readonly && !archived
&& (sendError || !composerReady || researchBlock || blockedCommand || !agent) && (
&& (sendError || researchBlock || blockedCommand || !agent) && (
<div
className={sendError ? "composer-notice motion-feedback" : "composer-notice"}
data-motion-feedback={sendError ? "alert" : undefined}
Expand All @@ -603,15 +697,9 @@ export function Chat(
>
{sendError
? <WarningIcon className="icon-danger" size={14} />
: connectionLost
? <WarningIcon size={14} />
: !composerReady
? <LoaderIcon className="chat-tool-loader" size={14} />
: <InfoIcon size={14} />}
<span>
{sendError ?? (!composerReady
? connected ? "Synchronizing…" : connectionLost ? "Connection lost" : "Connecting…"
: researchBlock
{sendError ?? (researchBlock
? researchCopy[researchBlock]
: blockedCommand
? "Research isn’t available in this document"
Expand All @@ -627,15 +715,6 @@ export function Chat(
Retry
</button>
)
: !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">
Expand All @@ -650,7 +729,7 @@ export function Chat(
</div>
)}
<div
aria-busy={submitting}
aria-busy={sending}
className="composer-surface field"
data-mode={effectiveMode ? "chopin" : "chat"}
data-error={!!sendError || undefined}
Expand Down Expand Up @@ -717,14 +796,15 @@ export function Chat(
: undefined}
aria-describedby={[
referencesEnabled ? instructionsId : undefined,
sendError || !composerReady || researchBlock || blockedCommand || !agent
sendError || researchBlock || blockedCommand || !agent
? cueId
: undefined,
connectionLabel ? connectionId : undefined,
].filter(Boolean).join(" ") || undefined}
aria-disabled={!composerReady || submitting}
aria-disabled={!draftable || sending}
aria-invalid={!!sendError || undefined}
aria-expanded={commandOpen || pickerOpen || mentionOpen}
readOnly={!composerReady || submitting}
readOnly={!draftable || sending}
role="combobox"
resetKey={historyKey}
historyGroupKey={mode}
Expand Down Expand Up @@ -788,7 +868,7 @@ export function Chat(
}
if (
!composing && event.key === "Tab" && event.shiftKey && !event.metaKey
&& !event.ctrlKey && !event.altKey && agent && composerReady && !submitting
&& !event.ctrlKey && !event.altKey && agent && draftable && !sending
) {
event.preventDefault();
if (!event.repeat) toggleMode();
Expand Down Expand Up @@ -870,7 +950,7 @@ export function Chat(
<div className="composer-left">
<ModeSwitch
effectiveMode={effectiveMode}
disabled={!composerReady || submitting || !agent}
disabled={!draftable || sending || !agent}
onToggle={toggleMode}
/>

Expand Down Expand Up @@ -898,13 +978,34 @@ export function Chat(
)}
</div>
<div className="composer-actions">
{/* Always mounted, so the live region hears its first word. */}
<span className="composer-connection" id={connectionId} role="status">
{connectionLabel && (
<span
className="composer-connection-label"
data-offline={connectionLabel === "Offline" || undefined}
>
{connectionLabel}
</span>
)}
</span>
{/* Where the document header is out of view; see composer.css. */}
{connectionNotice === "offline" && wire && (
<button
className="composer-reconnect btn btn-sm btn-ghost"
onClick={() => wire.reconnect()}
type="button"
>
Reconnect
</button>
)}
<span className="composer-run-control">
{agent && (busy || counts.active > 0) && (
<button
aria-label="Stop Chopin"
disabled={!composerReady}
disabled={!composerReady && connectionNotice !== "none"}
className="btn btn-icon btn-secondary"
onClick={() => wire?.send("chat:abort")}
onClick={() => control("chat:abort")}
title="Stop Chopin"
type="button"
>
Expand All @@ -914,9 +1015,9 @@ export function Chat(
{agent && !busy && !counts.active && counts.paused > 0 && (
<button
aria-label="Resume Chopin"
disabled={!composerReady}
disabled={!composerReady && connectionNotice !== "none"}
className="btn btn-icon btn-secondary"
onClick={() => wire?.send("chat:resume")}
onClick={() => control("chat:resume")}
title="Resume Chopin"
type="button"
>
Expand All @@ -925,9 +1026,10 @@ export function Chat(
)}
</span>
<SendAction
busy={submitting}
disabled={!composerReady || submitting || !draft.text.trim()
|| blockedCommand}
busy={sending}
disabled={!draftable || sending || !draft.text.trim()
|| blockedCommand
|| (connectionNotice !== "none" && !draftCommand(draft.text))}
onClick={submit}
label="Send message"
/>
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/chat/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,51 @@
height: 1.75rem;
flex-shrink: 0;
}

/* Inside the footer row, so losing the connection moves nothing above it. */
.chat-composer .composer-connection {
display: inline-flex;
min-width: 0;
}
.chat-composer .composer-connection-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding-inline: 0.25rem;
color: var(--color-text-secondary);
font-size: var(--text-xs);
line-height: var(--text-xs--line-height);
white-space: nowrap;
}
.chat-composer .composer-connection-label::before {
content: "";
width: 0.375rem;
height: 0.375rem;
flex: none;
border-radius: var(--radius-full);
background: var(--color-warning);
}
/* The same words, tone and dot as the document's own status in its header. */
.chat-composer .composer-connection-label[data-offline] {
color: var(--color-destructive-ink);
}
.chat-composer .composer-connection-label[data-offline]::before {
display: none;
}
@keyframes composer-connection-enter {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: no-preference) {
.chat-composer .composer-connection-label {
animation: composer-connection-enter var(--duration-fast) var(--ease-out);
}
}
/*
* Side by side with the document, its header already offers Reconnect, and
* one state gets one action. Without it in view, as on a phone, Chat does.
*/
[data-workspace-mode="split"] .chat-composer .composer-reconnect {
display: none;
}
Loading
Loading