* media: check ffmpeg filter support before subtitle operations * sandbox: handle synchronous spawn errors safely in desktop sandbox * Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency. What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens. Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally. * Update translation catalogs Re-extracted catalogs for the new and moved strings; the catalog sync test passes. * Add project plan and test readiness notes Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes. * fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps * fix(ci): restore space deletion guard, browser toggle, and run-steps contracts * fix(ci): restore model resolution errors, group stage, and onboarding flow * fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs * fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding * test(web): wait for avatar and rig visibility in oo-avatar motion spec * test(web): wait for persisted message IDs in message-hover-actions pagination spec * Onboarding: always drop the local voice when the voice step is skipped Skipping waited only for the latest voice load and disconnected only when the client saw the connect succeed. An earlier or client-failed connect could still persist the local credential, so a user who skipped voice was left with one. Wait for every load and always disconnect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs * Shell: hide the optimistic copy once the transcript shows the send A message sent while the bot works streamed into the transcript before its send request resolved, so the same text showed twice until the optimistic bubble was removed. Drop an optimistic message as soon as a persisted user message with the same text appears. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs --------- Co-authored-by: Claude <noreply@anthropic.com>
869 lines
31 KiB
TypeScript
869 lines
31 KiB
TypeScript
import { t } from "@lingui/core/macro";
|
|
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import type { AgentSkillCatalogEntry, ThreadMessage } from "@rakazo/contracts";
|
|
import { ATTACHMENT_ALLOWED_MIME_TYPES } from "@rakazo/contracts";
|
|
import type { ComposerMention, SlashActionId } from "@rakazo/core";
|
|
import {
|
|
clampMentionHighlightIndex,
|
|
mentionChipKey,
|
|
resolveMentionPickerKey,
|
|
SLASH_ACTIONS,
|
|
serializeComposerPrompt,
|
|
truncateSlashDescription,
|
|
} from "@rakazo/core";
|
|
import {
|
|
BotAvatar,
|
|
Button,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@rakazo/ui-web";
|
|
import {
|
|
ArrowUp,
|
|
Box,
|
|
Clock,
|
|
Mic,
|
|
Paperclip,
|
|
Play,
|
|
Plus,
|
|
Puzzle,
|
|
Settings,
|
|
Square,
|
|
X,
|
|
} from "lucide-react";
|
|
import type { ClipboardEvent, DragEvent, RefObject } from "react";
|
|
import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { VoiceAuroraRim } from "../../components/voice/VoiceAurora";
|
|
import { isFileDrag, isFilePaste } from "../../lib/pending-attachments";
|
|
import { AutopilotDialog } from "./autopilot-dialog";
|
|
import { previewMessageText } from "./message-preview";
|
|
import type { PendingAttachment } from "./shell-types";
|
|
import { FALLBACK_BOT_COLOR } from "./shell-types";
|
|
|
|
// ".zip" too: Windows reports ZIPs as application/x-zip-compressed.
|
|
const ATTACHMENT_ACCEPT = [...ATTACHMENT_ALLOWED_MIME_TYPES, ".zip"].join(",");
|
|
|
|
const addButtonClass =
|
|
"tactile-press size-10 shrink-0 rounded-full border border-border bg-foreground/5 text-foreground/80 transition-all hover:bg-foreground/10 hover:text-foreground flex items-center justify-center cursor-pointer";
|
|
|
|
export const Composer = memo(function Composer({
|
|
running,
|
|
disabled,
|
|
pendingAttachments,
|
|
attachmentNotice,
|
|
sendError,
|
|
runError,
|
|
runErrorId,
|
|
onRunErrorPresented,
|
|
onDismissError,
|
|
sending,
|
|
fileInputRef,
|
|
onAttachmentPick,
|
|
onRemoveAttachment,
|
|
onSend,
|
|
onStop,
|
|
onVoice,
|
|
voiceReady = false,
|
|
voiceColor,
|
|
replyTarget,
|
|
replyQuote,
|
|
replyTargetName,
|
|
onClearReply,
|
|
mentionTargets,
|
|
agentSkills,
|
|
onSlashOpen,
|
|
onSlashAction,
|
|
onConnectModel,
|
|
onAutopilot,
|
|
}: {
|
|
running: boolean;
|
|
disabled?: boolean;
|
|
pendingAttachments: PendingAttachment[];
|
|
attachmentNotice: string | null;
|
|
sendError: string | null;
|
|
runError: string | null;
|
|
runErrorId: string | null;
|
|
onRunErrorPresented: (runId: string) => void;
|
|
onDismissError: () => void;
|
|
sending: boolean;
|
|
fileInputRef: RefObject<HTMLInputElement | null>;
|
|
onAttachmentPick: (files: FileList | null) => void | Promise<void>;
|
|
onRemoveAttachment: (attachment: PendingAttachment) => void;
|
|
onSend: (text: string, mentions?: ComposerMention[]) => Promise<void>;
|
|
onStop: () => Promise<void>;
|
|
onVoice?: () => void;
|
|
voiceReady?: boolean;
|
|
voiceColor?: string;
|
|
replyTarget?: ThreadMessage | null;
|
|
replyQuote?: string | null;
|
|
replyTargetName?: string;
|
|
onClearReply?: () => void;
|
|
mentionTargets?: ComposerMention[];
|
|
agentSkills?: AgentSkillCatalogEntry[];
|
|
onSlashOpen?: () => void;
|
|
onSlashAction?: (action: SlashActionId) => void;
|
|
/** Set while no model is connected: nothing can run until one is. */
|
|
onConnectModel?: () => void;
|
|
/** Hand the order to the autopilot; the composer menu offers it only when set. */
|
|
onAutopilot?: (goal: string, until?: string) => Promise<void>;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const [draft, setDraft] = useState("");
|
|
const [autopilotOpen, setAutopilotOpen] = useState(false);
|
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
|
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
|
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
|
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
|
|
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const runErrorRef = useRef<HTMLDivElement>(null);
|
|
const submitFingerprint = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (!sendError && !runError) return;
|
|
const timer = setTimeout(() => {
|
|
onDismissError();
|
|
}, 8000);
|
|
return () => clearTimeout(timer);
|
|
}, [sendError, runError, onDismissError]);
|
|
|
|
const presentedRunErrorIdRef = useRef<string | null>(null);
|
|
const mentionListboxId = useId();
|
|
const dragDepth = useRef(0);
|
|
const [draggingFiles, setDraggingFiles] = useState(false);
|
|
const canSend =
|
|
draft.trim().length > 0 ||
|
|
selectedSkill !== null ||
|
|
selectedMentions.length > 0 ||
|
|
pendingAttachments.length > 0;
|
|
// One slot, two jobs: an empty draft talks, a written one sends.
|
|
const voiceInSlot = Boolean(onVoice) && !canSend;
|
|
const primaryAction = voiceInSlot ? (
|
|
<Button
|
|
key="voice"
|
|
size="icon"
|
|
aria-label={t`Voice`}
|
|
title={voiceReady ? t`Start voice conversation` : t`Set up voice`}
|
|
disabled={disabled}
|
|
onClick={onVoice}
|
|
data-testid="composer-voice-primary"
|
|
className="tactile-press relative size-10 shrink-0 overflow-hidden rounded-full transition-all active:scale-90 animate-in fade-in zoom-in-75 duration-150 flex items-center justify-center cursor-pointer"
|
|
>
|
|
{voiceReady ? (
|
|
<VoiceAuroraRim botColor={voiceColor} className="inset-x-[22%] h-0.5 blur-[3px]" />
|
|
) : null}
|
|
<Mic strokeWidth={2.2} className="size-icon-lg" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
key="send"
|
|
size="icon"
|
|
aria-label={t`Send`}
|
|
disabled={!canSend || disabled}
|
|
onClick={send}
|
|
className={`tactile-press size-10 shrink-0 rounded-full transition-all active:scale-90 disabled:bg-foreground/[0.08] disabled:text-foreground/20 flex items-center justify-center cursor-pointer ${
|
|
onVoice ? "animate-in fade-in zoom-in-75 duration-150" : ""
|
|
}`}
|
|
>
|
|
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
|
|
</Button>
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!runError || !runErrorId) return;
|
|
const currentRunErrorId = runErrorId;
|
|
let frame = 0;
|
|
function recordIfPresented() {
|
|
cancelAnimationFrame(frame);
|
|
frame = requestAnimationFrame(() => {
|
|
const element = runErrorRef.current;
|
|
if (!element || document.visibilityState !== "visible") return;
|
|
const rect = element.getBoundingClientRect();
|
|
const topElement = document.elementFromPoint(
|
|
rect.left + rect.width / 2,
|
|
rect.top + rect.height / 2,
|
|
);
|
|
if (topElement && (topElement === element || element.contains(topElement))) {
|
|
if (presentedRunErrorIdRef.current === currentRunErrorId) return;
|
|
presentedRunErrorIdRef.current = currentRunErrorId;
|
|
onRunErrorPresented(currentRunErrorId);
|
|
}
|
|
});
|
|
}
|
|
recordIfPresented();
|
|
const observer = new MutationObserver(recordIfPresented);
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
document.addEventListener("transitionend", recordIfPresented);
|
|
document.addEventListener("visibilitychange", recordIfPresented);
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
observer.disconnect();
|
|
document.removeEventListener("transitionend", recordIfPresented);
|
|
document.removeEventListener("visibilitychange", recordIfPresented);
|
|
};
|
|
}, [onRunErrorPresented, runError, runErrorId]);
|
|
|
|
useLayoutEffect(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
|
|
function syncHeight() {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
textarea.style.height = "0px";
|
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
}
|
|
|
|
syncHeight();
|
|
let lastWidth = el.getBoundingClientRect().width;
|
|
const observer = new ResizeObserver(() => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
const width = textarea.getBoundingClientRect().width;
|
|
if (width === lastWidth) return;
|
|
lastWidth = width;
|
|
syncHeight();
|
|
});
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [draft]);
|
|
|
|
function updateDraft(value: string) {
|
|
submitFingerprint.current = null;
|
|
setDraft(value);
|
|
const mentionMatch = /(?:^|\s)@([\w-]*)$/.exec(value);
|
|
setMentionQuery(mentionMatch ? (mentionMatch[1] ?? "") : null);
|
|
// `/` only at the start of the draft so forced skills expand (`Use skill:` / `/Name` prefix).
|
|
const slashMatch = selectedSkill === null ? /^\/([^\n]*)$/.exec(value) : null;
|
|
const nextSlash = slashMatch ? (slashMatch[1] ?? "") : null;
|
|
if (nextSlash !== null && slashQuery === null) onSlashOpen?.();
|
|
setSlashQuery(nextSlash);
|
|
}
|
|
|
|
function focusComposer() {
|
|
textareaRef.current?.focus();
|
|
}
|
|
|
|
function insertMention(mention: ComposerMention) {
|
|
setDraft((current) => current.replace(/@([\w-]*)$/, ""));
|
|
setMentionQuery(null);
|
|
setMentionHighlightIndex(0);
|
|
setSelectedMentions((current) =>
|
|
current.some((selected) => mentionChipKey(selected) === mentionChipKey(mention))
|
|
? current
|
|
: [...current, mention],
|
|
);
|
|
focusComposer();
|
|
}
|
|
|
|
function insertSkill(skill: AgentSkillCatalogEntry) {
|
|
setSelectedSkill(skill);
|
|
setDraft("");
|
|
setSlashQuery(null);
|
|
}
|
|
|
|
function runSlashAction(action: SlashActionId) {
|
|
setDraft("");
|
|
setSlashQuery(null);
|
|
onSlashAction?.(action);
|
|
}
|
|
|
|
function removeLastChip() {
|
|
if (selectedMentions.length > 0) {
|
|
setSelectedMentions((current) => current.slice(0, -1));
|
|
return;
|
|
}
|
|
if (selectedSkill) setSelectedSkill(null);
|
|
}
|
|
|
|
const mentionOptions = useMemo(() => {
|
|
if (mentionQuery === null || !mentionTargets?.length) return [];
|
|
const query = mentionQuery.trim().toLowerCase();
|
|
return mentionTargets
|
|
.filter((target) => !query || target.name.toLowerCase().startsWith(query))
|
|
.slice(0, 10);
|
|
}, [mentionQuery, mentionTargets]);
|
|
|
|
useEffect(() => {
|
|
setMentionHighlightIndex(0);
|
|
}, [mentionQuery]);
|
|
|
|
const activeMentionIndex = clampMentionHighlightIndex(
|
|
mentionHighlightIndex,
|
|
mentionOptions.length,
|
|
);
|
|
const mentionPickerOpen = mentionOptions.length > 0;
|
|
const activeMentionOptionId = mentionPickerOpen
|
|
? `${mentionListboxId}-option-${activeMentionIndex}`
|
|
: undefined;
|
|
|
|
const slashSkillOptions = useMemo(() => {
|
|
if (slashQuery === null) return [];
|
|
const query = slashQuery.trim().toLowerCase();
|
|
const skills = agentSkills ?? [];
|
|
return skills
|
|
.filter((skill) => {
|
|
if (!query) return true;
|
|
return (
|
|
skill.name.toLowerCase().includes(query) ||
|
|
skill.description.toLowerCase().includes(query)
|
|
);
|
|
})
|
|
.slice(0, 8);
|
|
}, [agentSkills, slashQuery]);
|
|
|
|
const slashActionOptions = useMemo(() => {
|
|
if (slashQuery === null) return [];
|
|
const query = slashQuery.trim().toLowerCase();
|
|
return SLASH_ACTIONS.filter((action) => !query || action.label.toLowerCase().includes(query));
|
|
}, [slashQuery]);
|
|
|
|
const showSlashPicker =
|
|
slashQuery !== null &&
|
|
mentionQuery === null &&
|
|
(slashSkillOptions.length > 0 || slashActionOptions.length > 0);
|
|
|
|
function send() {
|
|
if (!canSend || disabled) return;
|
|
const text = serializeComposerPrompt(draft, selectedSkill, selectedMentions);
|
|
const fingerprint = [text, ...selectedMentions.map((mention) => mentionChipKey(mention))].join(
|
|
"\n",
|
|
);
|
|
if (submitFingerprint.current === fingerprint) return;
|
|
submitFingerprint.current = fingerprint;
|
|
setDraft("");
|
|
setMentionQuery(null);
|
|
setMentionHighlightIndex(0);
|
|
setSlashQuery(null);
|
|
setSelectedSkill(null);
|
|
const mentions = selectedMentions;
|
|
setSelectedMentions([]);
|
|
void onSend(text, mentions);
|
|
}
|
|
|
|
function handleDragEnter(event: DragEvent<HTMLFieldSetElement>) {
|
|
const dataTransfer = event.dataTransfer;
|
|
if (!isFileDrag(dataTransfer)) return;
|
|
event.preventDefault();
|
|
if (disabled) {
|
|
dragDepth.current = 0;
|
|
setDraggingFiles(false);
|
|
return;
|
|
}
|
|
dragDepth.current += 1;
|
|
setDraggingFiles(true);
|
|
}
|
|
|
|
function handleDragOver(event: DragEvent<HTMLFieldSetElement>) {
|
|
const dataTransfer = event.dataTransfer;
|
|
if (!isFileDrag(dataTransfer)) return;
|
|
event.preventDefault();
|
|
dataTransfer.dropEffect = disabled ? "none" : "copy";
|
|
if (disabled) {
|
|
dragDepth.current = 0;
|
|
setDraggingFiles(false);
|
|
return;
|
|
}
|
|
setDraggingFiles(true);
|
|
}
|
|
|
|
function handleDragLeave(event: DragEvent<HTMLFieldSetElement>) {
|
|
if (!isFileDrag(event.dataTransfer)) return;
|
|
if (disabled) {
|
|
dragDepth.current = 0;
|
|
setDraggingFiles(false);
|
|
return;
|
|
}
|
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
if (dragDepth.current === 0) setDraggingFiles(false);
|
|
}
|
|
|
|
function handleDrop(event: DragEvent<HTMLFieldSetElement>) {
|
|
const dataTransfer = event.dataTransfer;
|
|
if (!isFileDrag(dataTransfer)) return;
|
|
event.preventDefault();
|
|
dragDepth.current = 0;
|
|
setDraggingFiles(false);
|
|
if (!disabled) void onAttachmentPick(dataTransfer.files);
|
|
}
|
|
|
|
function handlePaste(event: ClipboardEvent<HTMLTextAreaElement>) {
|
|
const clipboardData = event.clipboardData;
|
|
// Only intercept real FileList pastes; leave text-only / empty-files native.
|
|
if (disabled || !clipboardData || !isFilePaste(clipboardData)) return;
|
|
event.preventDefault();
|
|
void onAttachmentPick(clipboardData.files);
|
|
const text = clipboardData.getData("text/plain");
|
|
if (!text) return;
|
|
const textarea = event.currentTarget;
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
updateDraft(`${draft.slice(0, start)}${text}${draft.slice(end)}`);
|
|
const caret = start + text.length;
|
|
window.requestAnimationFrame(() => {
|
|
textareaRef.current?.setSelectionRange(caret, caret);
|
|
});
|
|
}
|
|
|
|
const showComposerPlaceholder =
|
|
draft.length === 0 && selectedSkill === null && selectedMentions.length === 0;
|
|
const replyName = replyTarget ? (replyTargetName ?? previewMessageText(replyTarget)) : "";
|
|
|
|
return (
|
|
<fieldset
|
|
aria-label={t`Message composer`}
|
|
data-dragging={draggingFiles ? "files" : undefined}
|
|
onDragEnter={handleDragEnter}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={`relative z-30 m-0 min-w-0 border-0 px-3 pb-4 pt-3 md:px-6 md:pb-6 ${
|
|
draggingFiles ? "rounded-[14px] ring-2 ring-inset ring-ring" : ""
|
|
}`}
|
|
>
|
|
<div className="mx-auto w-full max-w-4xl">
|
|
{onConnectModel ? (
|
|
<div
|
|
data-testid="composer-model-needed"
|
|
className="mb-3 flex items-center gap-3 rounded-[14px] border border-border bg-muted px-4 py-2 text-ui-body-sm text-foreground"
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<Trans>Connect a model so your Oo can reply.</Trans>
|
|
</span>
|
|
<Button size="sm" onClick={onConnectModel}>
|
|
<Trans>Connect model</Trans>
|
|
</Button>
|
|
</div>
|
|
) : sendError || runError ? (
|
|
<div
|
|
ref={runErrorRef}
|
|
role="alert"
|
|
data-testid="composer-error"
|
|
className="mb-3 flex items-center gap-2 rounded-[14px] border border-destructive/40 bg-destructive/10 px-4 py-2 text-ui-body-sm text-destructive"
|
|
>
|
|
<span className="min-w-0 flex-1">{sendError ?? runError}</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t`Dismiss error`}
|
|
data-testid="composer-error-dismiss"
|
|
onClick={() => {
|
|
onDismissError();
|
|
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
|
}}
|
|
className="shrink-0 text-destructive hover:text-foreground"
|
|
>
|
|
<X strokeWidth={2} className="size-icon-sm" />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{replyTarget ? (
|
|
<div
|
|
data-testid="reply-chip"
|
|
className="mb-2 flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1.5 text-ui-body-sm text-foreground/75"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
|
{replyQuote
|
|
? t`Replying to ${replyName}: “${replyQuote}”`
|
|
: t`Replying to ${replyName}`}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t`Cancel reply`}
|
|
onClick={onClearReply}
|
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X strokeWidth={2} className="size-icon-sm" />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{attachmentNotice ? (
|
|
<div className="mb-3 rounded-[14px] border border-warning/40 bg-warning/10 px-4 py-2 text-ui-body-sm text-warning">
|
|
{attachmentNotice}
|
|
</div>
|
|
) : null}
|
|
{pendingAttachments.length ? (
|
|
<div className="mb-3 flex flex-wrap gap-2">
|
|
{pendingAttachments.map((attachment) => (
|
|
<div
|
|
key={attachment.id}
|
|
className="flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1.5 text-ui-body-sm text-foreground/75"
|
|
>
|
|
{attachment.previewUrl ? (
|
|
<img
|
|
src={attachment.previewUrl}
|
|
alt={attachment.file.name}
|
|
className="size-control-md rounded object-cover"
|
|
/>
|
|
) : (
|
|
<Paperclip strokeWidth={1.8} className="size-icon-md" />
|
|
)}
|
|
<span className="max-w-[180px] truncate" dir="auto">
|
|
{attachment.file.name}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t`Remove ${attachment.file.name}`}
|
|
onClick={() => onRemoveAttachment(attachment)}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X strokeWidth={2} className="size-icon-sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{mentionPickerOpen ? (
|
|
<div
|
|
id={mentionListboxId}
|
|
role="listbox"
|
|
aria-label={t`Mentions`}
|
|
data-testid="mention-picker"
|
|
className="mb-2 max-h-64 overflow-y-auto rounded-[14px] border border-border bg-muted"
|
|
>
|
|
{mentionOptions.map((mention, index) => {
|
|
const optionId = `${mentionListboxId}-option-${index}`;
|
|
const highlighted = index === activeMentionIndex;
|
|
return (
|
|
<button
|
|
id={optionId}
|
|
key={mentionChipKey(mention)}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={highlighted}
|
|
aria-label={`@${mention.name}`}
|
|
data-mention-name={mention.name}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => insertMention(mention)}
|
|
onMouseEnter={() => setMentionHighlightIndex(index)}
|
|
className={`flex w-full items-start gap-3 px-4 py-2.5 text-start hover:bg-accent ${
|
|
highlighted ? "bg-accent" : ""
|
|
}`}
|
|
>
|
|
<span aria-hidden="true">
|
|
<MentionOptionIcon mention={mention} />
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span dir="auto" className="block text-ui-body text-foreground">
|
|
@{mention.name}
|
|
</span>
|
|
{mention.subtitle ? (
|
|
<span
|
|
dir="auto"
|
|
aria-hidden="true"
|
|
className="block truncate text-ui-body-sm text-muted-foreground"
|
|
>
|
|
{mention.subtitle}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
{showSlashPicker ? (
|
|
<div
|
|
data-testid="slash-picker"
|
|
className="mb-2 overflow-hidden rounded-[14px] border border-border bg-muted"
|
|
>
|
|
{slashSkillOptions.map((skill) => (
|
|
<button
|
|
key={skill.id}
|
|
type="button"
|
|
aria-label={t`Skill ${skill.name}`}
|
|
onClick={() => insertSkill(skill)}
|
|
className="flex w-full items-start gap-3 px-4 py-2.5 text-start hover:bg-accent"
|
|
>
|
|
<Box
|
|
strokeWidth={1.7}
|
|
className="mt-0.5 shrink-0 text-muted-foreground size-icon-md"
|
|
/>
|
|
<span className="min-w-0">
|
|
<span dir="auto" className="block text-ui-body text-foreground">
|
|
{skill.name}
|
|
</span>
|
|
<span dir="auto" className="block truncate text-ui-body-sm text-muted-foreground">
|
|
{truncateSlashDescription(skill.description)}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
{slashActionOptions.map((action) => {
|
|
const label = slashActionLabel(action.id);
|
|
return (
|
|
<button
|
|
key={action.id}
|
|
type="button"
|
|
aria-label={label}
|
|
onClick={() => runSlashAction(action.id)}
|
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-start hover:bg-accent"
|
|
>
|
|
<Settings
|
|
strokeWidth={1.7}
|
|
className="shrink-0 text-muted-foreground size-icon-md"
|
|
/>
|
|
<span className="text-ui-body text-foreground">{label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
<div
|
|
data-testid="composer-bar"
|
|
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200"
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={ATTACHMENT_ACCEPT}
|
|
className="hidden"
|
|
onChange={(event) => void onAttachmentPick(event.target.files)}
|
|
/>
|
|
{onAutopilot ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
disabled={disabled}
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label={t`Add`}
|
|
className={addButtonClass}
|
|
/>
|
|
}
|
|
>
|
|
<Plus strokeWidth={2.2} className="size-icon-lg" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="top" className="w-auto min-w-40">
|
|
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
|
|
<Paperclip />
|
|
{t`Attach file`}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setAutopilotOpen(true)}>
|
|
<Play />
|
|
{t`Autopilot`}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label={t`Attach file`}
|
|
disabled={disabled}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className={addButtonClass}
|
|
>
|
|
<Plus strokeWidth={2.2} className="size-icon-lg" />
|
|
</Button>
|
|
)}
|
|
{autopilotOpen && onAutopilot ? (
|
|
<AutopilotDialog
|
|
draft={draft}
|
|
onClose={() => setAutopilotOpen(false)}
|
|
onStart={async (goal, until) => {
|
|
await onAutopilot(goal, until);
|
|
setDraft("");
|
|
}}
|
|
/>
|
|
) : null}
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-1.5">
|
|
{selectedSkill ? (
|
|
<span
|
|
data-testid="skill-chip"
|
|
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border bg-foreground/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md"
|
|
>
|
|
<Box strokeWidth={1.8} className="shrink-0 text-muted-foreground size-icon-md" />
|
|
<span dir="auto" className="truncate">
|
|
{selectedSkill.name}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t`Remove skill ${selectedSkill.name}`}
|
|
onClick={() => setSelectedSkill(null)}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X strokeWidth={2} className="size-icon-sm" />
|
|
</button>
|
|
</span>
|
|
) : null}
|
|
{selectedMentions.map((mention) => (
|
|
<span
|
|
key={mentionChipKey(mention)}
|
|
data-testid="mention-chip"
|
|
data-mention-kind={mention.kind}
|
|
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border bg-foreground/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md"
|
|
>
|
|
<MentionChipIcon mention={mention} />
|
|
<span dir="auto" className="truncate">
|
|
{mention.name}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t`Remove mention ${mention.name}`}
|
|
onClick={() =>
|
|
setSelectedMentions((current) =>
|
|
current.filter(
|
|
(selected) => mentionChipKey(selected) !== mentionChipKey(mention),
|
|
),
|
|
)
|
|
}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X strokeWidth={2} className="size-icon-sm" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={draft}
|
|
onChange={(event) => updateDraft(event.target.value)}
|
|
onPaste={handlePaste}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
event.key === "Backspace" &&
|
|
draft.length === 0 &&
|
|
(selectedSkill !== null || selectedMentions.length > 0)
|
|
) {
|
|
event.preventDefault();
|
|
removeLastChip();
|
|
return;
|
|
}
|
|
const action = resolveMentionPickerKey({
|
|
key: event.key,
|
|
shiftKey: event.shiftKey,
|
|
isComposing: event.nativeEvent.isComposing || event.keyCode === 229,
|
|
optionCount: mentionOptions.length,
|
|
highlightedIndex: activeMentionIndex,
|
|
});
|
|
if (action.type === "complete") {
|
|
const mention = mentionOptions[action.index];
|
|
if (!mention) return;
|
|
event.preventDefault();
|
|
insertMention(mention);
|
|
return;
|
|
}
|
|
if (action.type === "move") {
|
|
event.preventDefault();
|
|
setMentionHighlightIndex(action.index);
|
|
return;
|
|
}
|
|
if (action.type === "dismiss") {
|
|
event.preventDefault();
|
|
setMentionQuery(null);
|
|
setMentionHighlightIndex(0);
|
|
return;
|
|
}
|
|
if (action.type === "send") {
|
|
event.preventDefault();
|
|
send();
|
|
}
|
|
}}
|
|
disabled={disabled}
|
|
placeholder={showComposerPlaceholder ? t`Message…` : undefined}
|
|
aria-label={t`Message`}
|
|
role="combobox"
|
|
aria-autocomplete="list"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={mentionPickerOpen ? "true" : "false"}
|
|
aria-controls={mentionPickerOpen ? mentionListboxId : undefined}
|
|
aria-activedescendant={activeMentionOptionId}
|
|
name="chat-message"
|
|
autoComplete="off"
|
|
dir="auto"
|
|
rows={1}
|
|
className="max-h-36 min-h-[28px] min-w-[8rem] flex-1 resize-none overflow-y-auto bg-transparent py-1 text-[16.5px] leading-[1.55] text-foreground outline-none placeholder:text-muted-foreground/75 disabled:opacity-40 font-normal"
|
|
/>
|
|
</div>
|
|
{running ? (
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{primaryAction}
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
aria-label={t`Stop`}
|
|
disabled={disabled || sending}
|
|
onClick={() => void onStop()}
|
|
className="tactile-press size-10 rounded-full border border-border bg-foreground/[0.08] text-foreground/90 shadow-sm transition-colors hover:bg-foreground/15 hover:text-foreground flex items-center justify-center cursor-pointer"
|
|
>
|
|
<Square strokeWidth={0} fill="currentColor" className="size-icon-sm" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
primaryAction
|
|
)}
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
});
|
|
|
|
function slashActionLabel(id: SlashActionId) {
|
|
switch (id) {
|
|
case "chat-settings":
|
|
return t`Chat Settings`;
|
|
case "settings-general":
|
|
return t`Settings: General`;
|
|
case "settings-usage":
|
|
return t`Settings: Usage`;
|
|
}
|
|
}
|
|
|
|
function MentionOptionIcon({ mention }: { mention: ComposerMention }) {
|
|
if (mention.kind === "routine") {
|
|
return (
|
|
<Clock strokeWidth={1.7} className="mt-0.5 shrink-0 text-muted-foreground size-icon-md" />
|
|
);
|
|
}
|
|
if (mention.kind === "connector") {
|
|
return (
|
|
<Puzzle strokeWidth={1.7} className="mt-0.5 shrink-0 text-muted-foreground size-icon-md" />
|
|
);
|
|
}
|
|
if (mention.kind === "group") {
|
|
return (
|
|
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-accent text-ui-caption text-foreground/75">
|
|
G
|
|
</span>
|
|
);
|
|
}
|
|
if (mention.kind === "everyone") {
|
|
return (
|
|
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-accent text-ui-caption text-foreground/75">
|
|
@
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<BotAvatar
|
|
color={mention.color ?? FALLBACK_BOT_COLOR}
|
|
identity={mention.id}
|
|
className="size-icon-md"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function MentionChipIcon({ mention }: { mention: ComposerMention }) {
|
|
if (mention.kind === "routine") {
|
|
return <Clock strokeWidth={1.7} className="shrink-0 text-muted-foreground/70 size-icon-sm" />;
|
|
}
|
|
if (mention.kind === "connector") {
|
|
return <Puzzle strokeWidth={1.7} className="shrink-0 text-muted-foreground/70 size-icon-sm" />;
|
|
}
|
|
if (mention.kind === "group" || mention.kind === "everyone") {
|
|
return (
|
|
<span className="grid h-4 w-4 shrink-0 place-items-center rounded-full bg-accent text-ui-caption text-foreground/75">
|
|
{mention.kind === "group" ? "G" : "@"}
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<BotAvatar
|
|
color={mention.color ?? FALLBACK_BOT_COLOR}
|
|
identity={mention.id}
|
|
className="size-icon-md"
|
|
/>
|
|
);
|
|
}
|