InputGroup's has-disabled: classes matched the disabled send button and greyed the whole empty composer; ChatComposer cancels both. Sortable session rows drop the always-on grip column: mouse drags the row (6px), touch long-presses (250ms), a drag never opens the row, and the grip stays only for keyboard sorting, visible on focus. The row menu shows on hover, focus or the current row on hover-capable devices. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { useSyncExternalStore } from "react";
|
|
import type { ChangeEvent, FormEvent, KeyboardEvent, ReactNode, RefObject } from "react";
|
|
import { ArrowUp, Square } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupTextarea,
|
|
} from "@/components/ui/input-group";
|
|
import { CharacterRemaining } from "@/components/character-remaining";
|
|
import { characterRemainingVisible } from "@/lib/character-remaining";
|
|
import {
|
|
composerDraftSnapshot,
|
|
serverComposerDraftSnapshot,
|
|
subscribeComposerDraft,
|
|
} from "@/lib/composer-draft";
|
|
import { CHAT_AI_NOTICE_ZH } from "@/lib/ai-generated-notice";
|
|
import { COMPOSER_QUEUE_LABEL, COMPOSER_QUEUE_RECALL_LABEL } from "@/lib/queued-draft";
|
|
|
|
export const composerRemainingId = "composer-character-remaining";
|
|
|
|
export function useComposerDraft(): string {
|
|
return useSyncExternalStore(
|
|
subscribeComposerDraft,
|
|
composerDraftSnapshot,
|
|
serverComposerDraftSnapshot,
|
|
);
|
|
}
|
|
|
|
type ChatComposerProps = {
|
|
readonly inputRef: RefObject<HTMLTextAreaElement | null>;
|
|
/**
|
|
* A surface that owns its own draft (the rectification session) passes it here;
|
|
* the main chat leaves it undefined and the composer reads the draft store.
|
|
*/
|
|
readonly value?: string;
|
|
readonly remainingId?: string;
|
|
/** Extra description (a question stem shown above the field) merged with the count id. */
|
|
readonly describedBy?: string;
|
|
readonly inputLabel: string;
|
|
readonly placeholder: string;
|
|
readonly maxLength: number;
|
|
readonly inputDisabled: boolean;
|
|
readonly submitLabel: string;
|
|
readonly submitBlocked: boolean;
|
|
readonly stopVisible: boolean;
|
|
readonly stopLabel: string;
|
|
readonly stopTitle: string;
|
|
readonly queued?: { text: string; onRecall: () => void };
|
|
/**
|
|
* Controls that belong to the composer itself (model picker, chart subject).
|
|
* They sit on the left of the field's bottom row. The send button is on the right.
|
|
*/
|
|
readonly toolbar?: ReactNode;
|
|
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
readonly onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
|
|
readonly onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
readonly onStop: () => void;
|
|
};
|
|
|
|
export function ChatComposer({
|
|
inputRef,
|
|
value,
|
|
remainingId = composerRemainingId,
|
|
describedBy,
|
|
inputLabel,
|
|
placeholder,
|
|
maxLength,
|
|
inputDisabled,
|
|
submitLabel,
|
|
submitBlocked,
|
|
stopVisible,
|
|
stopLabel,
|
|
stopTitle,
|
|
queued,
|
|
toolbar,
|
|
onSubmit,
|
|
onChange,
|
|
onKeyDown,
|
|
onStop,
|
|
}: ChatComposerProps) {
|
|
const storeDraft = useComposerDraft();
|
|
const draft = value ?? storeDraft;
|
|
const showRemaining = characterRemainingVisible(draft.length, maxLength);
|
|
const describedByIds = [describedBy, showRemaining ? remainingId : undefined].filter(Boolean).join(" ") || undefined;
|
|
|
|
return (
|
|
<>
|
|
{queued ? (
|
|
<div className="composer-queue" role="status">
|
|
<div className="composer-queue__body">
|
|
<p className="composer-queue__label">{COMPOSER_QUEUE_LABEL}</p>
|
|
<p className="composer-queue__text">{queued.text}</p>
|
|
</div>
|
|
<Button className="composer-queue__recall" type="button" variant="ghost" onClick={queued.onRecall}>
|
|
{COMPOSER_QUEUE_RECALL_LABEL}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<form className="composer" onSubmit={onSubmit}>
|
|
{/* `has-disabled:` in InputGroup greys and fades the whole group when any child is disabled, and the
|
|
send button is disabled whenever the box is empty (BUG-1466). Only the frame's own disabled look is wanted here. */}
|
|
<InputGroup className="border-0 bg-transparent shadow-none ring-0 has-disabled:bg-transparent has-disabled:opacity-100">
|
|
<InputGroupTextarea
|
|
ref={inputRef}
|
|
aria-label={inputLabel}
|
|
aria-describedby={describedByIds}
|
|
placeholder={placeholder}
|
|
rows={1}
|
|
maxLength={maxLength}
|
|
disabled={inputDisabled}
|
|
value={draft}
|
|
onChange={onChange}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
<InputGroupAddon align="block-end" className="composer-row">
|
|
{toolbar ? <div className="composer-tools">{toolbar}</div> : null}
|
|
<div className="composer-row-end">
|
|
{showRemaining ? (
|
|
<CharacterRemaining id={remainingId} length={draft.length} maxLength={maxLength} />
|
|
) : null}
|
|
{stopVisible ? (
|
|
<Button
|
|
className="composer-stop"
|
|
aria-label={stopLabel}
|
|
title={stopTitle}
|
|
size="icon"
|
|
type="button"
|
|
onClick={onStop}
|
|
>
|
|
<Square aria-hidden="true" />
|
|
</Button>
|
|
) : (
|
|
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
|
|
<ArrowUp aria-hidden="true" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</form>
|
|
<p className="composer-ai-notice">{CHAT_AI_NOTICE_ZH}</p>
|
|
</>
|
|
);
|
|
}
|