mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
2274 lines
99 KiB
TypeScript
2274 lines
99 KiB
TypeScript
"use client";
|
|
|
|
import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef, KeyboardEvent } from "react";
|
|
import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession";
|
|
import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft-store";
|
|
import {
|
|
MAX_ATTACHED_IMAGE_BYTES,
|
|
MAX_ATTACHED_IMAGES,
|
|
isBase64ImageWithinLimits,
|
|
} from "@/lib/image-attachments";
|
|
import {
|
|
buildEntriesFromFiles, buildAtInsertText, extractAtQuery, filterFileEntries,
|
|
type AtQueryMatch, type FileIndexEntry,
|
|
} from "@/lib/file-fuzzy";
|
|
import { FolderIcon, getFileIcon } from "./FileIcons";
|
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
import { useI18n } from "@/hooks/useI18n";
|
|
|
|
export interface AttachedImage {
|
|
data: string; // base64, no prefix
|
|
mimeType: string;
|
|
previewUrl: string; // object URL for display
|
|
}
|
|
|
|
interface ModelOption {
|
|
provider: string;
|
|
modelId: string;
|
|
name: string;
|
|
}
|
|
|
|
interface Props {
|
|
onSend: (message: string, images?: AttachedImage[]) => void;
|
|
onAbort: () => void;
|
|
onSteer?: (message: string, images?: AttachedImage[]) => void;
|
|
onFollowUp?: (message: string, images?: AttachedImage[]) => void;
|
|
onPromptWithStreamingBehavior?: (message: string, behavior: "steer" | "followUp", images?: AttachedImage[]) => void;
|
|
isStreaming: boolean;
|
|
model?: { provider: string; modelId: string } | null;
|
|
isAutoModelSelection?: boolean;
|
|
modelNames?: Record<string, string>;
|
|
modelList?: { id: string; name: string; provider: string }[];
|
|
modelError?: string | null;
|
|
onModelChange?: (provider: string, modelId: string) => void;
|
|
onCompact?: () => void;
|
|
onAbortCompaction?: () => void;
|
|
isCompacting?: boolean;
|
|
compactError?: string | null;
|
|
compactResult?: CompactResultInfo | null;
|
|
toolPreset?: "none" | "default" | "full";
|
|
onToolPresetChange?: (preset: "none" | "default" | "full") => void;
|
|
thinkingLevel?: "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
onThinkingLevelChange?: (level: "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max") => void;
|
|
availableThinkingLevels?: string[] | null;
|
|
thinkingLevelMap?: Record<string, string | null> | null;
|
|
retryInfo?: { attempt: number; maxAttempts: number; errorMessage?: string } | null;
|
|
queuedMessages?: QueuedMessages | null;
|
|
inputHistory?: string[];
|
|
onRecallQueue?: () => void;
|
|
slashCommands?: SlashCommandInfo[];
|
|
slashCommandsLoading?: boolean;
|
|
onLoadSlashCommands?: () => Promise<SlashCommandInfo[]> | SlashCommandInfo[];
|
|
onBuiltinCommand?: (message: string) => Promise<BuiltinSlashCommandResult>;
|
|
soundEnabled?: boolean;
|
|
onSoundToggle?: () => void;
|
|
onAudioUnlock?: () => void;
|
|
draftKey?: string;
|
|
/** Session working directory — enables the @ file autocomplete menu */
|
|
cwd?: string | null;
|
|
}
|
|
|
|
export interface ChatInputHandle {
|
|
insertText: (text: string) => void;
|
|
insertIfEmpty: (text: string) => void;
|
|
prependText: (text: string) => void;
|
|
addImages: (files: File[]) => void;
|
|
}
|
|
|
|
const TOOL_PRESETS = ["off", "default", "full"] as const;
|
|
const TOOL_PRESET_MAP: Record<"off" | "default" | "full", "none" | "default" | "full"> = { off: "none", default: "default", full: "full" };
|
|
const COMPOSITION_END_ENTER_GRACE_MS = 100;
|
|
const MODEL_FILTER_THRESHOLD = 8;
|
|
const MODEL_OPTION_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
|
|
|
function compareModelOptions(a: ModelOption, b: ModelOption): number {
|
|
return MODEL_OPTION_COLLATOR.compare(a.name || a.modelId, b.name || b.modelId)
|
|
|| MODEL_OPTION_COLLATOR.compare(a.provider, b.provider)
|
|
|| MODEL_OPTION_COLLATOR.compare(a.modelId, b.modelId);
|
|
}
|
|
|
|
export function filterModelOptions(options: ModelOption[], query: string): ModelOption[] {
|
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
if (!normalizedQuery) return options;
|
|
|
|
return options.filter((option) => (
|
|
`${option.name} ${option.modelId}`
|
|
.toLocaleLowerCase()
|
|
.includes(normalizedQuery)
|
|
));
|
|
}
|
|
|
|
const THINKING_LEVELS = ["auto", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
const THINKING_LEVEL_DESC_KEYS: Record<typeof THINKING_LEVELS[number], string> = {
|
|
auto: "chat.thinkingUseDefault", off: "chat.thinkingOff", minimal: "chat.thinkingMinimal", low: "chat.thinkingLow",
|
|
medium: "chat.thinkingMedium", high: "chat.thinkingHigh", xhigh: "chat.thinkingXhigh", max: "chat.thinkingMax",
|
|
};
|
|
|
|
function formatTokenCount(tokens: number): string {
|
|
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
|
return tokens.toLocaleString();
|
|
}
|
|
|
|
type SlashCommandPaletteItem = SlashCommandInfo | {
|
|
name: string;
|
|
description: string;
|
|
source: "builtin";
|
|
};
|
|
|
|
type SlashCommandSource = SlashCommandPaletteItem["source"];
|
|
|
|
const BUILTIN_SLASH_COMMANDS: SlashCommandPaletteItem[] = [
|
|
{ name: "compact", description: "chat.commandCompact", source: "builtin" },
|
|
{ name: "reload", description: "chat.commandReload", source: "builtin" },
|
|
{ name: "name", description: "chat.commandName", source: "builtin" },
|
|
{ name: "session", description: "chat.commandSession", source: "builtin" },
|
|
{ name: "copy", description: "chat.commandCopy", source: "builtin" },
|
|
];
|
|
|
|
const SLASH_SOURCES: SlashCommandSource[] = ["builtin", "extension", "prompt", "skill"];
|
|
|
|
const SLASH_SOURCE_GROUP_LABEL_KEYS: Record<SlashCommandSource, string> = {
|
|
builtin: "chat.builtIn",
|
|
extension: "chat.extensions",
|
|
prompt: "chat.prompts",
|
|
skill: "chat.skills",
|
|
};
|
|
|
|
const SLASH_SOURCE_ORDER: Record<SlashCommandSource, number> = {
|
|
builtin: 0,
|
|
extension: 1,
|
|
prompt: 2,
|
|
skill: 3,
|
|
};
|
|
|
|
function slashMatchRank(command: SlashCommandPaletteItem, query: string, t: (key: string) => string): number {
|
|
const name = command.name.toLowerCase();
|
|
const description = getSlashDescription(command, t).toLowerCase();
|
|
if (name === query) return 0;
|
|
if (name.startsWith(query)) return 1;
|
|
if (name.includes(query)) return 2;
|
|
if (description.includes(query)) return 3;
|
|
return 4;
|
|
}
|
|
|
|
function getSlashDescription(command: SlashCommandPaletteItem, t: (key: string) => string): string {
|
|
return command.source === "builtin" ? t(command.description) : command.description ?? "";
|
|
}
|
|
|
|
function imageToDraftImage(image: AttachedImage): ChatDraftImage {
|
|
return { data: image.data, mimeType: image.mimeType };
|
|
}
|
|
|
|
function draftImageToAttachedImage(image: ChatDraftImage): AttachedImage {
|
|
return {
|
|
...image,
|
|
previewUrl: `data:${image.mimeType};base64,${image.data}`,
|
|
};
|
|
}
|
|
|
|
function draftImagesToAttachedImages(images: ChatDraftImage[] | undefined): AttachedImage[] {
|
|
return (images ?? [])
|
|
.filter(isBase64ImageWithinLimits)
|
|
.slice(0, MAX_ATTACHED_IMAGES)
|
|
.map(draftImageToAttachedImage);
|
|
}
|
|
|
|
function revokeImagePreview(image: AttachedImage): void {
|
|
if (image.previewUrl.startsWith("blob:")) {
|
|
URL.revokeObjectURL(image.previewUrl);
|
|
}
|
|
}
|
|
|
|
function QueuedMessageRow({ kind, text }: { kind: "steer" | "follow-up"; text: string }) {
|
|
return (
|
|
<div
|
|
title={text}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "3px 10px",
|
|
fontSize: 12,
|
|
color: "var(--text-muted)",
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
flexShrink: 0,
|
|
fontSize: 10,
|
|
fontFamily: "var(--font-mono)",
|
|
padding: "1px 7px",
|
|
borderRadius: 999,
|
|
border: `1px solid ${kind === "steer" ? "color-mix(in srgb, var(--accent) 45%, transparent)" : "var(--border)"}`,
|
|
color: kind === "steer" ? "var(--accent)" : "var(--text-dim)",
|
|
}}
|
|
>
|
|
{kind}
|
|
</span>
|
|
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{text}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ModelErrorBanner({ error }: { error?: string | null }) {
|
|
if (!error) return null;
|
|
return (
|
|
<div
|
|
role="alert"
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 8,
|
|
maxHeight: 120,
|
|
marginBottom: 8,
|
|
padding: "7px 10px",
|
|
overflowY: "auto",
|
|
border: "1px solid rgba(239,68,68,0.3)",
|
|
borderRadius: 6,
|
|
background: "rgba(239,68,68,0.07)",
|
|
color: "#ef4444",
|
|
fontSize: 11,
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
<svg
|
|
width="13"
|
|
height="13"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
style={{ flexShrink: 0, marginTop: 1 }}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0Z" />
|
|
<line x1="12" y1="9" x2="12" y2="13" />
|
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
</svg>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ fontWeight: 600 }}>Model error</div>
|
|
<div style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{error}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
|
|
onSend, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, onModelChange,
|
|
onCompact, onAbortCompaction, isCompacting, compactError, compactResult, toolPreset, onToolPresetChange,
|
|
thinkingLevel, onThinkingLevelChange, availableThinkingLevels, thinkingLevelMap,
|
|
retryInfo, queuedMessages, inputHistory = [], onRecallQueue,
|
|
slashCommands, slashCommandsLoading, onLoadSlashCommands,
|
|
onBuiltinCommand,
|
|
soundEnabled, onSoundToggle, onAudioUnlock,
|
|
onPromptWithStreamingBehavior,
|
|
draftKey,
|
|
cwd,
|
|
}: Props, ref) {
|
|
const { t } = useI18n();
|
|
const isMobile = useIsMobile();
|
|
const [value, setValue] = useState(() => (draftKey ? getDraft(draftKey)?.value ?? "" : ""));
|
|
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
|
const [modelDropdownRect, setModelDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null);
|
|
const [modelFilter, setModelFilter] = useState("");
|
|
const [toolDropdownOpen, setToolDropdownOpen] = useState(false);
|
|
const [thinkingDropdownOpen, setThinkingDropdownOpen] = useState(false);
|
|
const [controlsMenuOpen, setControlsMenuOpen] = useState(false);
|
|
const [attachedImages, setAttachedImages] = useState<AttachedImage[]>(() => (
|
|
draftKey ? draftImagesToAttachedImages(getDraft(draftKey)?.images) : []
|
|
));
|
|
const trimmedValue = value.trimStart();
|
|
const bashMode = attachedImages.length === 0 && trimmedValue.startsWith("!");
|
|
const bashExcluded = bashMode && trimmedValue.startsWith("!!");
|
|
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
|
|
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
|
const [atQuery, setAtQuery] = useState<AtQueryMatch | null>(null);
|
|
const [atMenuOpen, setAtMenuOpen] = useState(false);
|
|
const [atActiveIndex, setAtActiveIndex] = useState(0);
|
|
const [historyMenuOpen, setHistoryMenuOpen] = useState(false);
|
|
const [historyActiveIndex, setHistoryActiveIndex] = useState(0);
|
|
const [fileIndex, setFileIndex] = useState<{ cwd: string; entries: FileIndexEntry[]; truncated: boolean } | null>(null);
|
|
const [fileIndexLoading, setFileIndexLoading] = useState(false);
|
|
const [atServerResult, setAtServerResult] = useState<{ cwd: string; query: string; matches: FileIndexEntry[] } | null>(null);
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const modelDropdownPanelRef = useRef<HTMLDivElement>(null);
|
|
const toolDropdownRef = useRef<HTMLDivElement>(null);
|
|
const thinkingDropdownRef = useRef<HTMLDivElement>(null);
|
|
const controlsMenuRef = useRef<HTMLDivElement>(null);
|
|
const historyMenuRef = useRef<HTMLDivElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const isComposingRef = useRef(false);
|
|
const lastCompositionEndAtRef = useRef(0);
|
|
const slashCommandsRequestedRef = useRef(false);
|
|
const slashItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
const atItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
const historyItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
const fileIndexMetaRef = useRef<{ cwd: string; fetchedAt: number } | null>(null);
|
|
const fileIndexFetchingRef = useRef<string | null>(null);
|
|
const draftKeyRef = useRef(draftKey);
|
|
const valueRef = useRef(value);
|
|
const attachedImagesRef = useRef(attachedImages);
|
|
const pendingImageCountRef = useRef(0);
|
|
valueRef.current = value;
|
|
attachedImagesRef.current = attachedImages;
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
insertIfEmpty(text: string) {
|
|
const ta = textareaRef.current;
|
|
const current = ta ? ta.value : value;
|
|
if (current.trim()) return;
|
|
setValue(text);
|
|
setAtQuery(null);
|
|
requestAnimationFrame(() => {
|
|
if (!ta) return;
|
|
ta.focus();
|
|
ta.style.height = "auto";
|
|
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
|
});
|
|
},
|
|
prependText(text: string) {
|
|
if (!text.trim()) return;
|
|
const ta = textareaRef.current;
|
|
const current = ta ? ta.value : value;
|
|
// Mirrors the TUI's queue restore: queued text first, then whatever
|
|
// the user already typed, separated by a blank line.
|
|
const combined = [text, current].filter((t) => t.trim()).join("\n\n");
|
|
setValue(combined);
|
|
setAtQuery(null);
|
|
requestAnimationFrame(() => {
|
|
if (!ta) return;
|
|
ta.focus();
|
|
ta.setSelectionRange(combined.length, combined.length);
|
|
ta.style.height = "auto";
|
|
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
|
});
|
|
},
|
|
insertText(text: string) {
|
|
const ta = textareaRef.current;
|
|
if (!ta) {
|
|
setValue((v) => v + (v ? " " : "") + text);
|
|
return;
|
|
}
|
|
const start = ta.selectionStart ?? ta.value.length;
|
|
const end = ta.selectionEnd ?? ta.value.length;
|
|
const before = ta.value.slice(0, start);
|
|
const after = ta.value.slice(end);
|
|
const sep = before.length > 0 && !before.endsWith(" ") ? " " : "";
|
|
const newVal = before + sep + text + after;
|
|
setValue(newVal);
|
|
setAtQuery(null);
|
|
requestAnimationFrame(() => {
|
|
if (!ta) return;
|
|
const pos = start + sep.length + text.length;
|
|
ta.setSelectionRange(pos, pos);
|
|
ta.focus();
|
|
ta.style.height = "auto";
|
|
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
|
});
|
|
},
|
|
addImages(files: File[]) {
|
|
processImageFiles(files);
|
|
},
|
|
}));
|
|
|
|
const processImageFiles = useCallback(async (files: File[]) => {
|
|
if (isStreaming) return;
|
|
const remaining = Math.max(
|
|
0,
|
|
MAX_ATTACHED_IMAGES - attachedImagesRef.current.length - pendingImageCountRef.current,
|
|
);
|
|
const imageFiles = files
|
|
.filter((f) => f.type.startsWith("image/") && f.size <= MAX_ATTACHED_IMAGE_BYTES)
|
|
.slice(0, remaining);
|
|
if (!imageFiles.length) return;
|
|
pendingImageCountRef.current += imageFiles.length;
|
|
try {
|
|
const newImages = await Promise.all(
|
|
imageFiles.map(
|
|
(file) =>
|
|
new Promise<AttachedImage>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const result = reader.result as string;
|
|
// result is "data:<mime>;base64,<data>"
|
|
const base64 = result.split(",")[1];
|
|
resolve({ data: base64, mimeType: file.type, previewUrl: URL.createObjectURL(file) });
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
})
|
|
)
|
|
);
|
|
setAttachedImages((prev) => {
|
|
const accepted = newImages.slice(0, Math.max(0, MAX_ATTACHED_IMAGES - prev.length));
|
|
newImages.slice(accepted.length).forEach(revokeImagePreview);
|
|
return [...prev, ...accepted];
|
|
});
|
|
} finally {
|
|
pendingImageCountRef.current -= imageFiles.length;
|
|
}
|
|
}, [isStreaming]);
|
|
|
|
const removeImage = useCallback((index: number) => {
|
|
setAttachedImages((prev) => {
|
|
const next = [...prev];
|
|
const [removed] = next.splice(index, 1);
|
|
if (removed) revokeImagePreview(removed);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const clearImages = useCallback(() => {
|
|
setAttachedImages((prev) => {
|
|
prev.forEach(revokeImagePreview);
|
|
return [];
|
|
});
|
|
}, []);
|
|
|
|
const clearInput = useCallback(() => {
|
|
setValue("");
|
|
setAtQuery(null);
|
|
setHistoryMenuOpen(false);
|
|
if (draftKey) clearDraft(draftKey);
|
|
if (draftKeyRef.current && draftKeyRef.current !== draftKey) clearDraft(draftKeyRef.current);
|
|
clearImages();
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = "auto";
|
|
}
|
|
}, [clearImages, draftKey]);
|
|
|
|
useEffect(() => {
|
|
if (!draftKey || draftKeyRef.current !== draftKey) return;
|
|
setDraft(draftKey, {
|
|
value,
|
|
images: attachedImages.map(imageToDraftImage),
|
|
});
|
|
}, [attachedImages, draftKey, value]);
|
|
|
|
useEffect(() => {
|
|
const previousDraftKey = draftKeyRef.current;
|
|
if (previousDraftKey === draftKey) return;
|
|
|
|
if (previousDraftKey) {
|
|
setDraft(previousDraftKey, {
|
|
value: valueRef.current,
|
|
images: attachedImagesRef.current.map(imageToDraftImage),
|
|
});
|
|
}
|
|
|
|
const draft = draftKey ? getDraft(draftKey) : null;
|
|
draftKeyRef.current = draftKey;
|
|
setValue(draft?.value ?? "");
|
|
setAtQuery(null);
|
|
setHistoryMenuOpen(false);
|
|
setAttachedImages((prev) => {
|
|
prev.forEach(revokeImagePreview);
|
|
return draftImagesToAttachedImages(draft?.images);
|
|
});
|
|
}, [draftKey]);
|
|
|
|
useEffect(() => {
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
ta.style.height = "auto";
|
|
if (value) ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
attachedImagesRef.current.forEach(revokeImagePreview);
|
|
};
|
|
}, []);
|
|
|
|
const handleSend = useCallback(async () => {
|
|
const msg = value.trim();
|
|
if (!msg && !attachedImages.length) return;
|
|
if (isStreaming) return;
|
|
onAudioUnlock?.();
|
|
if (!attachedImages.length && msg.startsWith("/") && onBuiltinCommand) {
|
|
const result = await onBuiltinCommand(msg);
|
|
if (result.handled) {
|
|
if (!result.error) clearInput();
|
|
return;
|
|
}
|
|
}
|
|
onSend(msg, attachedImages.length ? attachedImages : undefined);
|
|
clearInput();
|
|
}, [value, attachedImages, isStreaming, onBuiltinCommand, onSend, clearInput, onAudioUnlock]);
|
|
|
|
const slashQuery = value.startsWith("/") && !/\s/.test(value.slice(1))
|
|
? value.slice(1).toLowerCase()
|
|
: null;
|
|
|
|
const filteredSlashCommands = (() => {
|
|
if (slashQuery === null) return [];
|
|
const commands = [...(isStreaming ? [] : BUILTIN_SLASH_COMMANDS), ...(slashCommands ?? [])];
|
|
return [...commands]
|
|
.filter((command) => {
|
|
const name = command.name.toLowerCase();
|
|
const description = getSlashDescription(command, t).toLowerCase();
|
|
return name.includes(slashQuery) || description.includes(slashQuery);
|
|
})
|
|
.sort((a, b) => {
|
|
const rankDelta = slashMatchRank(a, slashQuery, t) - slashMatchRank(b, slashQuery, t);
|
|
if (rankDelta !== 0) return rankDelta;
|
|
return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source]
|
|
|| MODEL_OPTION_COLLATOR.compare(a.name, b.name);
|
|
});
|
|
})();
|
|
|
|
const groupedSlashCommands = (() => {
|
|
const groups = new Map<SlashCommandSource, { source: SlashCommandSource; items: { command: SlashCommandPaletteItem; index: number }[] }>();
|
|
for (const source of SLASH_SOURCES) {
|
|
groups.set(source, { source, items: [] });
|
|
}
|
|
filteredSlashCommands.forEach((command, index) => {
|
|
groups.get(command.source)?.items.push({ command, index });
|
|
});
|
|
return SLASH_SOURCES
|
|
.map((source) => groups.get(source)!)
|
|
.filter((group) => group.items.length > 0);
|
|
})();
|
|
|
|
const slashCommandCountLabel = filteredSlashCommands.length === 1
|
|
? t(slashQuery ? "chat.match" : "chat.command")
|
|
: t(slashQuery ? "chat.matches" : "chat.commands", { count: filteredSlashCommands.length });
|
|
const hasInputText = Boolean(value.trim());
|
|
const canQueueStreamingMessage = hasInputText && attachedImages.length === 0;
|
|
|
|
// ── @ file autocomplete ──────────────────────────────────────────────────
|
|
// Recomputed from the text before the caret on every change/caret move.
|
|
// Disabled entirely when there is no cwd (new session without a directory).
|
|
const updateAtQuery = useCallback((text: string, cursor: number | null) => {
|
|
if (!cwd) {
|
|
setAtQuery(null);
|
|
return;
|
|
}
|
|
const pos = cursor ?? text.length;
|
|
setAtQuery(extractAtQuery(text.slice(0, pos)));
|
|
}, [cwd]);
|
|
|
|
const atQueryText = atQuery?.query ?? null;
|
|
const atLocalMatches: FileIndexEntry[] = React.useMemo(() => (
|
|
atQueryText !== null && fileIndex && fileIndex.cwd === cwd
|
|
? filterFileEntries(fileIndex.entries, atQueryText)
|
|
: []
|
|
), [atQueryText, fileIndex, cwd]);
|
|
|
|
// When the client index is truncated (repo larger than the index cap),
|
|
// local filtering cannot see deep files, so queries are also ranked
|
|
// server-side against the full listing. Local matches render immediately
|
|
// and are replaced when the (debounced) server result for the current
|
|
// query arrives; stale responses are ignored via the query/cwd tag.
|
|
const needsServerSearch = Boolean(atQueryText && fileIndex?.truncated && fileIndex.cwd === cwd);
|
|
useEffect(() => {
|
|
if (!needsServerSearch || !cwd || !atQueryText) return;
|
|
const fetchCwd = cwd;
|
|
const query = atQueryText;
|
|
const timer = setTimeout(() => {
|
|
fetch(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}&q=${encodeURIComponent(query)}`)
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`file search failed: ${res.status}`);
|
|
return res.json() as Promise<{ matches?: FileIndexEntry[] }>;
|
|
})
|
|
.then((data) => setAtServerResult({ cwd: fetchCwd, query, matches: data.matches ?? [] }))
|
|
.catch(() => {
|
|
// Keep showing local matches; the next keystroke retries.
|
|
});
|
|
}, 150);
|
|
return () => clearTimeout(timer);
|
|
}, [needsServerSearch, atQueryText, cwd]);
|
|
|
|
const serverResultInUse = needsServerSearch
|
|
&& atServerResult !== null
|
|
&& atServerResult.cwd === cwd
|
|
&& atServerResult.query === atQueryText;
|
|
const atMatches: FileIndexEntry[] = serverResultInUse ? atServerResult.matches : atLocalMatches;
|
|
|
|
// Open/reset the menu whenever the @token appears or changes (mirrors the
|
|
// slash menu: Escape closes it, the next keystroke re-opens it).
|
|
const atTokenKey = atQuery === null ? null : `${atQuery.start}:${atQuery.quoted ? 1 : 0}:${atQuery.query}`;
|
|
useEffect(() => {
|
|
if (atTokenKey === null) {
|
|
setAtMenuOpen(false);
|
|
setAtActiveIndex(0);
|
|
return;
|
|
}
|
|
setAtMenuOpen(true);
|
|
setAtActiveIndex(0);
|
|
}, [atTokenKey]);
|
|
|
|
// Fetch the file index when the menu opens. The server caches per cwd for
|
|
// ~10s, so re-opening refreshes cheaply; while typing nothing refetches.
|
|
const atTokenActive = atQuery !== null;
|
|
useEffect(() => {
|
|
if (!atTokenActive || !cwd) return;
|
|
const meta = fileIndexMetaRef.current;
|
|
if (meta && meta.cwd === cwd && Date.now() - meta.fetchedAt < 10_000) return;
|
|
if (fileIndexFetchingRef.current === cwd) return;
|
|
fileIndexFetchingRef.current = cwd;
|
|
const fetchCwd = cwd;
|
|
setFileIndexLoading(true);
|
|
fetch(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}`)
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`file index failed: ${res.status}`);
|
|
return res.json() as Promise<{ files?: string[]; truncated?: boolean }>;
|
|
})
|
|
.then((data) => {
|
|
setFileIndex({ cwd: fetchCwd, entries: buildEntriesFromFiles(data.files ?? []), truncated: !!data.truncated });
|
|
fileIndexMetaRef.current = { cwd: fetchCwd, fetchedAt: Date.now() };
|
|
})
|
|
.catch(() => {
|
|
// Leave any previous index in place; next open retries.
|
|
fileIndexMetaRef.current = null;
|
|
})
|
|
.finally(() => {
|
|
fileIndexFetchingRef.current = null;
|
|
setFileIndexLoading(false);
|
|
});
|
|
}, [atTokenActive, cwd]);
|
|
|
|
const applyAtCompletion = useCallback((entry: FileIndexEntry) => {
|
|
if (!atQuery) return;
|
|
const ta = textareaRef.current;
|
|
const cursor = ta?.selectionStart ?? value.length;
|
|
const before = value.slice(0, atQuery.start);
|
|
let after = value.slice(cursor);
|
|
// Completing inside a quoted token (@"my dir/… with the caret before the
|
|
// closing quote): the replacement carries its own closing quote, so drop
|
|
// the old one right after the caret (mirrors the TUI's applyCompletion).
|
|
if (atQuery.quoted && after.startsWith('"')) {
|
|
after = after.slice(1);
|
|
}
|
|
const insert = buildAtInsertText(entry.path, entry.isDir, atQuery.quoted);
|
|
const newValue = before + insert.text + after;
|
|
const newPos = before.length + insert.cursorOffset;
|
|
setValue(newValue);
|
|
// setValue alone does not fire onChange — re-derive the token here. Files
|
|
// end with a space (token closes, menu hides); directories end with "/"
|
|
// before the caret (token stays open for drill-down into the directory).
|
|
setAtQuery(extractAtQuery(newValue.slice(0, newPos)));
|
|
requestAnimationFrame(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.focus();
|
|
el.setSelectionRange(newPos, newPos);
|
|
el.style.height = "auto";
|
|
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
|
});
|
|
}, [atQuery, value]);
|
|
|
|
useEffect(() => {
|
|
if (atActiveIndex >= atMatches.length) {
|
|
setAtActiveIndex(Math.max(0, atMatches.length - 1));
|
|
}
|
|
}, [atMatches.length, atActiveIndex]);
|
|
|
|
useEffect(() => {
|
|
atItemRefs.current.length = atMatches.length;
|
|
}, [atMatches.length]);
|
|
|
|
useEffect(() => {
|
|
if (!atMenuOpen) return;
|
|
atItemRefs.current[atActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}, [atActiveIndex, atMenuOpen]);
|
|
|
|
useEffect(() => {
|
|
if (historyActiveIndex >= inputHistory.length) {
|
|
setHistoryActiveIndex(Math.max(0, inputHistory.length - 1));
|
|
}
|
|
}, [inputHistory.length, historyActiveIndex]);
|
|
|
|
useEffect(() => {
|
|
historyItemRefs.current.length = inputHistory.length;
|
|
}, [inputHistory.length]);
|
|
|
|
useEffect(() => {
|
|
if (!historyMenuOpen) return;
|
|
historyItemRefs.current[historyActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}, [historyActiveIndex, historyMenuOpen]);
|
|
|
|
const applyHistoryInput = useCallback((text: string) => {
|
|
setValue(text);
|
|
setHistoryMenuOpen(false);
|
|
setHistoryActiveIndex(0);
|
|
setAtQuery(null);
|
|
requestAnimationFrame(() => {
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
ta.focus();
|
|
ta.setSelectionRange(text.length, text.length);
|
|
ta.style.height = "auto";
|
|
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
|
});
|
|
}, []);
|
|
|
|
const applySlashCommand = useCallback((command: SlashCommandPaletteItem) => {
|
|
const nextValue = `/${command.name} `;
|
|
setValue(nextValue);
|
|
setSlashMenuOpen(false);
|
|
setSlashActiveIndex(0);
|
|
requestAnimationFrame(() => {
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
ta.focus();
|
|
ta.setSelectionRange(nextValue.length, nextValue.length);
|
|
ta.style.height = "auto";
|
|
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
|
});
|
|
}, []);
|
|
|
|
const sendQueued = useCallback((mode: "steer" | "followup") => {
|
|
const msg = value.trim();
|
|
if (!msg && !attachedImages.length) return;
|
|
if (attachedImages.length) return;
|
|
onAudioUnlock?.();
|
|
const streamingBehavior = mode === "steer" ? "steer" : "followUp";
|
|
if (msg.startsWith("/") && onPromptWithStreamingBehavior) {
|
|
onPromptWithStreamingBehavior(msg, streamingBehavior, attachedImages.length ? attachedImages : undefined);
|
|
clearInput();
|
|
return;
|
|
}
|
|
if (mode === "steer" && onSteer) {
|
|
onSteer(msg, attachedImages.length ? attachedImages : undefined);
|
|
} else if (mode === "followup" && onFollowUp) {
|
|
onFollowUp(msg, attachedImages.length ? attachedImages : undefined);
|
|
}
|
|
clearInput();
|
|
}, [value, attachedImages, onPromptWithStreamingBehavior, onSteer, onFollowUp, clearInput, onAudioUnlock]);
|
|
|
|
const getNextSlashIndex = useCallback((direction: "up" | "down" | "left" | "right") => {
|
|
const lastIndex = filteredSlashCommands.length - 1;
|
|
if (lastIndex < 0) return 0;
|
|
|
|
if (direction === "left") return Math.max(0, slashActiveIndex - 1);
|
|
if (direction === "right") return Math.min(lastIndex, slashActiveIndex + 1);
|
|
|
|
const currentNode = slashItemRefs.current[slashActiveIndex];
|
|
if (!currentNode) {
|
|
return direction === "down"
|
|
? Math.min(lastIndex, slashActiveIndex + 1)
|
|
: Math.max(0, slashActiveIndex - 1);
|
|
}
|
|
|
|
const currentRect = currentNode.getBoundingClientRect();
|
|
const currentX = currentRect.left + currentRect.width / 2;
|
|
const currentY = currentRect.top + currentRect.height / 2;
|
|
let bestIndex = -1;
|
|
let bestScore = Number.POSITIVE_INFINITY;
|
|
|
|
for (let index = 0; index <= lastIndex; index += 1) {
|
|
if (index === slashActiveIndex) continue;
|
|
const node = slashItemRefs.current[index];
|
|
if (!node) continue;
|
|
const rect = node.getBoundingClientRect();
|
|
const candidateY = rect.top + rect.height / 2;
|
|
const verticalDelta = candidateY - currentY;
|
|
if (direction === "down" ? verticalDelta <= 4 : verticalDelta >= -4) continue;
|
|
|
|
const candidateX = rect.left + rect.width / 2;
|
|
const score = Math.abs(verticalDelta) * 1000 + Math.abs(candidateX - currentX);
|
|
if (score < bestScore) {
|
|
bestIndex = index;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
|
|
if (bestIndex >= 0) return bestIndex;
|
|
return direction === "down"
|
|
? Math.min(lastIndex, slashActiveIndex + 1)
|
|
: Math.max(0, slashActiveIndex - 1);
|
|
}, [filteredSlashCommands.length, slashActiveIndex]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
const nativeEvent = e.nativeEvent;
|
|
const recentlyComposed = Date.now() - lastCompositionEndAtRef.current < COMPOSITION_END_ENTER_GRACE_MS;
|
|
const isComposing =
|
|
isComposingRef.current ||
|
|
nativeEvent.isComposing ||
|
|
nativeEvent.keyCode === 229;
|
|
|
|
if (e.key === "Enter" && !e.shiftKey && (isComposing || recentlyComposed)) {
|
|
if (recentlyComposed) e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (historyMenuOpen && !isComposing) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setHistoryActiveIndex((i) => Math.min(Math.max(0, inputHistory.length - 1), i + 1));
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setHistoryActiveIndex((i) => Math.max(0, i - 1));
|
|
return;
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setHistoryMenuOpen(false);
|
|
return;
|
|
}
|
|
if ((e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) && inputHistory[historyActiveIndex]) {
|
|
e.preventDefault();
|
|
applyHistoryInput(inputHistory[historyActiveIndex]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (slashMenuOpen && slashQuery !== null) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSlashActiveIndex(getNextSlashIndex("down"));
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSlashActiveIndex(getNextSlashIndex("up"));
|
|
return;
|
|
}
|
|
if (e.key === "ArrowRight") {
|
|
e.preventDefault();
|
|
setSlashActiveIndex(getNextSlashIndex("right"));
|
|
return;
|
|
}
|
|
if (e.key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
setSlashActiveIndex(getNextSlashIndex("left"));
|
|
return;
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setSlashMenuOpen(false);
|
|
return;
|
|
}
|
|
if ((e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) && filteredSlashCommands[slashActiveIndex]) {
|
|
e.preventDefault();
|
|
applySlashCommand(filteredSlashCommands[slashActiveIndex]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// @ file menu — skip while composing so IME candidate navigation
|
|
// (arrows/Enter/Tab) is never intercepted.
|
|
if (atMenuOpen && atQuery !== null && !isComposing) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setAtActiveIndex((i) => Math.min(Math.max(0, atMatches.length - 1), i + 1));
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setAtActiveIndex((i) => Math.max(0, i - 1));
|
|
return;
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setAtMenuOpen(false);
|
|
return;
|
|
}
|
|
if ((e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) && atMatches[atActiveIndex]) {
|
|
e.preventDefault();
|
|
applyAtCompletion(atMatches[atActiveIndex]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === "ArrowUp" && !isComposing && !isStreaming && inputHistory.length > 0 && value.trim().length === 0) {
|
|
e.preventDefault();
|
|
setSlashMenuOpen(false);
|
|
setAtMenuOpen(false);
|
|
setHistoryActiveIndex(inputHistory.length - 1);
|
|
setHistoryMenuOpen(true);
|
|
return;
|
|
}
|
|
|
|
// Esc stops the agent when no slash/@/history menu or IME composition is active.
|
|
if (e.key === "Escape" && !isComposing && isStreaming && onAbort) {
|
|
e.preventDefault();
|
|
onAbort();
|
|
return;
|
|
}
|
|
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (isStreaming && (onSteer || onFollowUp)) {
|
|
// Default Enter sends as steer if available, else followup
|
|
sendQueued(onSteer ? "steer" : "followup");
|
|
} else {
|
|
handleSend();
|
|
}
|
|
}
|
|
},
|
|
[isStreaming, onSteer, onFollowUp, onAbort, slashMenuOpen, slashQuery, filteredSlashCommands, slashActiveIndex, applySlashCommand, sendQueued, handleSend, getNextSlashIndex, atMenuOpen, atQuery, atMatches, atActiveIndex, applyAtCompletion, historyMenuOpen, inputHistory, historyActiveIndex, applyHistoryInput, value]
|
|
);
|
|
|
|
const handleInput = useCallback(() => {
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
ta.style.height = "auto";
|
|
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
|
}, []);
|
|
|
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
const items = Array.from(e.clipboardData?.items ?? []);
|
|
const imageItems = items.filter((item) => item.type.startsWith("image/"));
|
|
if (!imageItems.length) return;
|
|
e.preventDefault();
|
|
const files = imageItems.map((item) => item.getAsFile()).filter((f): f is File => f !== null);
|
|
processImageFiles(files);
|
|
}, [processImageFiles]);
|
|
|
|
useEffect(() => {
|
|
if (slashQuery === null) {
|
|
setSlashMenuOpen(false);
|
|
setSlashActiveIndex(0);
|
|
slashCommandsRequestedRef.current = false;
|
|
return;
|
|
}
|
|
setSlashMenuOpen(true);
|
|
setSlashActiveIndex(0);
|
|
if (!slashCommandsRequestedRef.current && onLoadSlashCommands) {
|
|
slashCommandsRequestedRef.current = true;
|
|
Promise.resolve(onLoadSlashCommands()).catch(() => {
|
|
slashCommandsRequestedRef.current = false;
|
|
});
|
|
}
|
|
}, [slashQuery, onLoadSlashCommands]);
|
|
|
|
useEffect(() => {
|
|
if (slashActiveIndex >= filteredSlashCommands.length) {
|
|
setSlashActiveIndex(Math.max(0, filteredSlashCommands.length - 1));
|
|
}
|
|
}, [filteredSlashCommands.length, slashActiveIndex]);
|
|
|
|
useEffect(() => {
|
|
slashItemRefs.current.length = filteredSlashCommands.length;
|
|
}, [filteredSlashCommands.length]);
|
|
|
|
useEffect(() => {
|
|
if (!slashMenuOpen) return;
|
|
slashItemRefs.current[slashActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}, [slashActiveIndex, slashMenuOpen]);
|
|
|
|
// Build model options: prefer modelList (has provider info), fallback to modelNames
|
|
const modelOptions: ModelOption[] = (() => {
|
|
if (modelList && modelList.length > 0) {
|
|
return modelList.map((m) => ({ provider: m.provider, modelId: m.id, name: m.name })).sort(compareModelOptions);
|
|
}
|
|
return Object.entries(modelNames ?? {}).map(([modelId, name]) => ({
|
|
provider: model?.provider ?? "unknown",
|
|
modelId,
|
|
name,
|
|
})).sort(compareModelOptions);
|
|
})();
|
|
const filteredModelOptions = filterModelOptions(modelOptions, modelFilter);
|
|
const showModelFilter = modelOptions.length > MODEL_FILTER_THRESHOLD;
|
|
|
|
// Group options by provider, preserving insertion order
|
|
const modelsByProvider: { provider: string; options: ModelOption[] }[] = [];
|
|
for (const opt of filteredModelOptions) {
|
|
const group = modelsByProvider.find((g) => g.provider === opt.provider);
|
|
if (group) group.options.push(opt);
|
|
else modelsByProvider.push({ provider: opt.provider, options: [opt] });
|
|
}
|
|
|
|
const displayModelName = model
|
|
? (modelOptions.find((o) => o.modelId === model.modelId && o.provider === model.provider)?.name ?? model.modelId)
|
|
: null;
|
|
const currentName = displayModelName;
|
|
|
|
const compactSavedTokens = compactResult
|
|
? Math.max(0, compactResult.tokensBefore - compactResult.estimatedTokensAfter)
|
|
: 0;
|
|
const compactResultText = compactResult
|
|
? `${compactResult.reason && compactResult.reason !== "manual" ? `${compactResult.reason[0].toUpperCase()}${compactResult.reason.slice(1)} ` : t("chat.compacted")} ${formatTokenCount(compactResult.tokensBefore)} -> ${formatTokenCount(compactResult.estimatedTokensAfter)} tokens (${t("chat.tokensSaved", { saved: formatTokenCount(compactSavedTokens) })})`
|
|
: null;
|
|
const thinkingDisplayLabel = (() => {
|
|
const lvl = thinkingLevel ?? "auto";
|
|
if (lvl === "auto" || !thinkingLevelMap) return lvl;
|
|
return thinkingLevelMap[lvl] ?? lvl;
|
|
})();
|
|
const toolPresetLabel = Object.entries(TOOL_PRESET_MAP).find(([, v]) => v === (toolPreset ?? "default"))?.[0] ?? "default";
|
|
|
|
// Close dropdowns on outside click
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (
|
|
dropdownRef.current && !dropdownRef.current.contains(e.target as Node) &&
|
|
modelDropdownPanelRef.current && !modelDropdownPanelRef.current.contains(e.target as Node)
|
|
) {
|
|
setModelDropdownOpen(false);
|
|
setModelFilter("");
|
|
}
|
|
if (toolDropdownRef.current && !toolDropdownRef.current.contains(e.target as Node)) {
|
|
setToolDropdownOpen(false);
|
|
}
|
|
if (thinkingDropdownRef.current && !thinkingDropdownRef.current.contains(e.target as Node)) {
|
|
setThinkingDropdownOpen(false);
|
|
}
|
|
if (controlsMenuRef.current && !controlsMenuRef.current.contains(e.target as Node)) {
|
|
setControlsMenuOpen(false);
|
|
}
|
|
if (historyMenuRef.current && !historyMenuRef.current.contains(e.target as Node) && !textareaRef.current?.contains(e.target as Node)) {
|
|
setHistoryMenuOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) setControlsMenuOpen(false);
|
|
}, [isMobile]);
|
|
|
|
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
flexShrink: 0,
|
|
background: "transparent",
|
|
padding: "0 16px 8px",
|
|
paddingRight: isMobile ? 16 : 52, // desktop: 16px base + 36px for ChatMinimap alignment
|
|
}}
|
|
>
|
|
{/* Hidden file input */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
disabled={isStreaming}
|
|
style={{ display: "none" }}
|
|
onChange={(e) => {
|
|
const files = Array.from(e.target.files ?? []);
|
|
processImageFiles(files);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<div style={{ maxWidth: 820, margin: "0 auto" }}>
|
|
<ModelErrorBanner error={modelError} />
|
|
{/* Queued steering / follow-up messages (delivered by pi on upcoming turns) */}
|
|
{((queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0)) > 0 && (
|
|
<div style={{
|
|
marginBottom: 8,
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 6,
|
|
background: "var(--bg-panel)",
|
|
padding: "5px 0",
|
|
}}>
|
|
<div style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 8,
|
|
padding: "2px 8px 4px 10px",
|
|
}}>
|
|
<span style={{
|
|
fontSize: 10,
|
|
fontFamily: "var(--font-mono)",
|
|
color: "var(--text-dim)",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.4,
|
|
}}>
|
|
{t("chat.queued", { count: (queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0) })}
|
|
</span>
|
|
{onRecallQueue && (
|
|
<button
|
|
onClick={onRecallQueue}
|
|
title={t("chat.recallTitle")}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "4px 12px",
|
|
fontSize: 12,
|
|
color: "var(--text)",
|
|
background: "transparent",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 7,
|
|
cursor: "pointer",
|
|
transition: "background 0.12s, border-color 0.12s",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = "var(--bg-hover)";
|
|
e.currentTarget.style.borderColor = "color-mix(in srgb, var(--accent) 45%, var(--border))";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = "transparent";
|
|
e.currentTarget.style.borderColor = "var(--border)";
|
|
}}
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="9 14 4 9 9 4" />
|
|
<path d="M20 20v-7a4 4 0 0 0-4-4H4" />
|
|
</svg>
|
|
{t("chat.recall")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{queuedMessages?.steering.map((text, i) => (
|
|
<QueuedMessageRow key={`steer-${i}`} kind="steer" text={text} />
|
|
))}
|
|
{queuedMessages?.followUp.map((text, i) => (
|
|
<QueuedMessageRow key={`followup-${i}`} kind="follow-up" text={text} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Retry banner */}
|
|
{retryInfo && (
|
|
<div style={{
|
|
marginBottom: 8, padding: "5px 10px",
|
|
background: "rgba(234,179,8,0.08)", border: "1px solid rgba(234,179,8,0.25)",
|
|
borderRadius: 6, fontSize: 12, color: "rgba(180,130,0,0.9)",
|
|
display: "flex", alignItems: "center", gap: 6,
|
|
}}>
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
<path d="M3 3v5h5" />
|
|
</svg>
|
|
{t("chat.retrying", { attempt: retryInfo.attempt, max: retryInfo.maxAttempts })}{retryInfo.errorMessage && <span style={{ opacity: 0.7, marginLeft: 4 }}>— {retryInfo.errorMessage}</span>}
|
|
</div>
|
|
)}
|
|
{compactResultText && (
|
|
<div style={{
|
|
marginBottom: 8, padding: "5px 10px",
|
|
background: "rgba(16,185,129,0.08)", border: "1px solid rgba(16,185,129,0.24)",
|
|
borderRadius: 6, fontSize: 12, color: "rgba(5,150,105,0.95)",
|
|
display: "flex", alignItems: "center", gap: 6,
|
|
}}>
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
{compactResultText}
|
|
</div>
|
|
)}
|
|
{compactError && (
|
|
<div
|
|
role="alert"
|
|
style={{
|
|
marginBottom: 8,
|
|
padding: "7px 10px",
|
|
background: "rgba(239,68,68,0.07)",
|
|
border: "1px solid rgba(239,68,68,0.3)",
|
|
borderRadius: 6,
|
|
color: "#ef4444",
|
|
fontFamily: "var(--font-mono)",
|
|
fontSize: 12,
|
|
lineHeight: 1.5,
|
|
whiteSpace: "pre-wrap",
|
|
overflowWrap: "anywhere",
|
|
}}
|
|
>
|
|
{compactError}
|
|
</div>
|
|
)}
|
|
{/* Image previews */}
|
|
{attachedImages.length > 0 && (
|
|
<div style={{ display: "flex", gap: 6, marginBottom: 6, flexWrap: "wrap" }}>
|
|
{attachedImages.map((img, i) => (
|
|
<div key={i} style={{ position: "relative", flexShrink: 0 }}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={img.previewUrl}
|
|
alt=""
|
|
style={{ width: 56, height: 56, objectFit: "cover", borderRadius: 6, border: "1px solid var(--border)", display: "block" }}
|
|
/>
|
|
<button
|
|
onClick={() => removeImage(i)}
|
|
style={{
|
|
position: "absolute", top: -4, right: -4,
|
|
width: 16, height: 16, borderRadius: "50%",
|
|
background: "var(--bg-panel)", border: "1px solid var(--border)",
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
cursor: "pointer", padding: 0, color: "var(--text-muted)",
|
|
}}
|
|
>
|
|
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
<line x1="1" y1="1" x2="7" y2="7" /><line x1="7" y1="1" x2="1" y2="7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Main input */}
|
|
<div style={{ position: "relative" }}>
|
|
{historyMenuOpen && inputHistory.length > 0 && (
|
|
<div
|
|
ref={historyMenuRef}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: "calc(100% + 8px)",
|
|
zIndex: 120,
|
|
background: "var(--bg)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 8,
|
|
boxShadow: "0 -6px 20px rgba(0,0,0,0.12)",
|
|
overflow: "hidden",
|
|
maxHeight: "min(44vh, 360px)",
|
|
}}
|
|
>
|
|
<div
|
|
title="Input history"
|
|
style={{
|
|
height: 30,
|
|
padding: "0 10px",
|
|
borderBottom: "1px solid var(--border)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
color: "var(--text-dim)",
|
|
}}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
|
<path d="M3 4v5h5" />
|
|
<path d="M12 7v5l3 2" />
|
|
</svg>
|
|
</div>
|
|
<div style={{ maxHeight: "calc(min(44vh, 360px) - 31px)", overflowY: "auto", padding: 4 }}>
|
|
{inputHistory.map((item, index) => {
|
|
const active = index === historyActiveIndex;
|
|
return (
|
|
<button
|
|
key={`${index}:${item}`}
|
|
ref={(node) => {
|
|
historyItemRefs.current[index] = node;
|
|
}}
|
|
type="button"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
applyHistoryInput(item);
|
|
}}
|
|
onMouseEnter={() => setHistoryActiveIndex(index)}
|
|
style={{
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 8,
|
|
padding: "7px 8px",
|
|
border: "none",
|
|
borderRadius: 6,
|
|
background: active ? "var(--bg-selected)" : "none",
|
|
color: "var(--text)",
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
fontSize: 12.5,
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
<span style={{ flexShrink: 0, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--text-dim)", paddingTop: 1 }}>
|
|
{index + 1}
|
|
</span>
|
|
<span style={{ minWidth: 0, display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 2, overflow: "hidden", overflowWrap: "anywhere" }}>
|
|
{item}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{slashMenuOpen && slashQuery !== null && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: "calc(100% + 8px)",
|
|
zIndex: 120,
|
|
background: "var(--bg)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 8,
|
|
boxShadow: "0 -6px 20px rgba(0,0,0,0.12)",
|
|
overflow: "hidden",
|
|
maxHeight: "min(56vh, 460px)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: "8px 10px",
|
|
borderBottom: "1px solid var(--border)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 8,
|
|
fontSize: 11,
|
|
color: "var(--text-dim)",
|
|
}}
|
|
>
|
|
<span>{slashCommandsLoading ? t("chat.loadingCommands") : t("chat.slashCommands", { label: slashCommandCountLabel })}</span>
|
|
<span style={{ fontFamily: "var(--font-mono)" }}>{t("chat.tabEnter")}</span>
|
|
</div>
|
|
<div style={{ maxHeight: "calc(min(56vh, 460px) - 34px)", overflowY: "auto", padding: 10 }}>
|
|
{!slashCommandsLoading && filteredSlashCommands.length === 0 ? (
|
|
<div style={{ padding: "2px 2px 4px", fontSize: 12, color: "var(--text-dim)" }}>
|
|
{t("chat.noCommands")}
|
|
</div>
|
|
) : (
|
|
groupedSlashCommands.map((group) => (
|
|
<section key={group.source} style={{ marginBottom: 12 }}>
|
|
<div
|
|
style={{
|
|
position: "sticky",
|
|
top: -10,
|
|
zIndex: 1,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 8,
|
|
padding: "4px 0 6px",
|
|
background: "var(--bg)",
|
|
color: "var(--text-dim)",
|
|
fontSize: 10,
|
|
fontWeight: 600,
|
|
textTransform: "uppercase",
|
|
}}
|
|
>
|
|
<span>{t(SLASH_SOURCE_GROUP_LABEL_KEYS[group.source])}</span>
|
|
<span style={{ fontFamily: "var(--font-mono)", fontWeight: 500 }}>{group.items.length}</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
{group.items.map(({ command, index }) => {
|
|
const active = index === slashActiveIndex;
|
|
return (
|
|
<button
|
|
key={`${command.source}:${command.name}`}
|
|
ref={(node) => {
|
|
slashItemRefs.current[index] = node;
|
|
}}
|
|
type="button"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
applySlashCommand(command);
|
|
}}
|
|
onMouseEnter={() => setSlashActiveIndex(index)}
|
|
style={{
|
|
width: "100%",
|
|
minWidth: 0,
|
|
minHeight: 58,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
justifyContent: "center",
|
|
padding: "9px 10px",
|
|
border: `1px solid ${active ? "var(--accent)" : "var(--border)"}`,
|
|
borderRadius: 7,
|
|
background: active ? "var(--bg-selected)" : "var(--bg-panel)",
|
|
color: "var(--text)",
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
boxShadow: active ? "0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent)" : "none",
|
|
}}
|
|
>
|
|
<span style={{
|
|
fontSize: 13,
|
|
fontFamily: "var(--font-mono)",
|
|
overflowWrap: "anywhere",
|
|
wordBreak: "break-word",
|
|
}}>
|
|
/{command.name}
|
|
</span>
|
|
{command.description && (
|
|
<span style={{
|
|
display: "-webkit-box",
|
|
WebkitBoxOrient: "vertical",
|
|
WebkitLineClamp: 2,
|
|
overflow: "hidden",
|
|
fontSize: 11,
|
|
lineHeight: 1.35,
|
|
color: "var(--text-dim)",
|
|
}}>
|
|
{getSlashDescription(command, t)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{atMenuOpen && atQuery !== null && (() => {
|
|
const indexLoading = fileIndexLoading && (!fileIndex || fileIndex.cwd !== cwd);
|
|
const matchCountLabel = atMatches.length === 1 ? t("chat.match") : t("chat.matches", { count: atMatches.length });
|
|
// With a truncated index, local results are provisional — the
|
|
// debounced server search over the full listing replaces them.
|
|
const truncatedHint = fileIndex?.truncated && !serverResultInUse
|
|
? (atQuery.query ? t("chat.searchingAll") : t("chat.indexTruncated"))
|
|
: "";
|
|
return (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: "calc(100% + 8px)",
|
|
zIndex: 120,
|
|
background: "var(--bg)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 8,
|
|
boxShadow: "0 -6px 20px rgba(0,0,0,0.12)",
|
|
overflow: "hidden",
|
|
maxHeight: "min(48vh, 400px)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: "8px 10px",
|
|
borderBottom: "1px solid var(--border)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 8,
|
|
fontSize: 11,
|
|
color: "var(--text-dim)",
|
|
}}
|
|
>
|
|
<span>
|
|
{indexLoading
|
|
? t("chat.loadingFiles")
|
|
: t("chat.files", { label: matchCountLabel, hint: truncatedHint })}
|
|
</span>
|
|
<span style={{ fontFamily: "var(--font-mono)" }}>{t("chat.tabEnter")}</span>
|
|
</div>
|
|
<div style={{ maxHeight: "calc(min(48vh, 400px) - 34px)", overflowY: "auto", padding: 4 }}>
|
|
{!indexLoading && atMatches.length === 0 ? (
|
|
<div style={{ padding: "6px 8px", fontSize: 12, color: "var(--text-dim)" }}>
|
|
{needsServerSearch && !serverResultInUse ? t("chat.searching") : t("chat.noMatchingFiles")}
|
|
</div>
|
|
) : (
|
|
atMatches.map((entry, index) => {
|
|
const active = index === atActiveIndex;
|
|
const name = entry.path.split("/").pop() ?? entry.path;
|
|
const dirPrefix = entry.path.slice(0, entry.path.length - name.length);
|
|
return (
|
|
<button
|
|
key={`${entry.isDir ? "d" : "f"}:${entry.path}`}
|
|
ref={(node) => {
|
|
atItemRefs.current[index] = node;
|
|
}}
|
|
type="button"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
applyAtCompletion(entry);
|
|
}}
|
|
onMouseEnter={() => setAtActiveIndex(index)}
|
|
style={{
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "6px 8px",
|
|
border: "none",
|
|
borderRadius: 6,
|
|
background: active ? "var(--bg-selected)" : "none",
|
|
color: "var(--text)",
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
fontSize: 12.5,
|
|
fontFamily: "var(--font-mono)",
|
|
}}
|
|
>
|
|
<span style={{ flexShrink: 0, display: "flex", alignItems: "center" }}>
|
|
{entry.isDir ? <FolderIcon size={14} /> : getFileIcon(name, 14)}
|
|
</span>
|
|
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
{dirPrefix && <span style={{ color: "var(--text-dim)" }}>{dirPrefix}</span>}
|
|
{name}
|
|
{entry.isDir && <span style={{ color: "var(--text-dim)" }}>/</span>}
|
|
</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 8,
|
|
alignItems: "center",
|
|
background: "var(--bg)",
|
|
border: `1px solid ${bashMode ? "var(--tool-bg)" : isStreaming && (onSteer || onFollowUp)
|
|
? "rgba(234,179,8,0.4)"
|
|
: "color-mix(in srgb, var(--border) 70%, transparent)"}`,
|
|
borderRadius: 14,
|
|
padding: "10px 10px 10px 14px",
|
|
boxShadow: "0 1px 2px rgba(15,23,42,0.04), 0 8px 24px -12px rgba(15,23,42,0.10)",
|
|
transition: "border-color 0.15s, background 0.15s, box-shadow 0.15s",
|
|
} as React.CSSProperties}
|
|
>
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={value}
|
|
onChange={(e) => {
|
|
setValue(e.target.value);
|
|
setHistoryMenuOpen(false);
|
|
updateAtQuery(e.target.value, e.target.selectionStart);
|
|
}}
|
|
onSelect={(e) => {
|
|
const el = e.currentTarget;
|
|
updateAtQuery(el.value, el.selectionStart);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onCompositionStart={() => {
|
|
isComposingRef.current = true;
|
|
}}
|
|
onCompositionEnd={(e) => {
|
|
isComposingRef.current = false;
|
|
lastCompositionEndAtRef.current = Date.now();
|
|
const el = e.currentTarget;
|
|
updateAtQuery(el.value, el.selectionStart);
|
|
}}
|
|
onInput={handleInput}
|
|
onPaste={handlePaste}
|
|
placeholder={
|
|
isStreaming && (onSteer || onFollowUp)
|
|
? t("chat.steerPlaceholder")
|
|
: isStreaming ? t("chat.agentPlaceholder")
|
|
: t("chat.messagePlaceholder")
|
|
}
|
|
rows={1}
|
|
style={{
|
|
flex: 1,
|
|
background: "none",
|
|
border: "none",
|
|
outline: "none",
|
|
resize: "none",
|
|
color: "var(--text)",
|
|
fontSize: 14,
|
|
lineHeight: 1.6,
|
|
fontFamily: "inherit",
|
|
minHeight: 24,
|
|
maxHeight: 200,
|
|
overflow: "auto",
|
|
}}
|
|
/>
|
|
|
|
{isStreaming ? (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0, alignSelf: "flex-end" }}>
|
|
{onSteer && (
|
|
<button
|
|
onClick={() => sendQueued("steer")}
|
|
disabled={!canQueueStreamingMessage}
|
|
title={attachedImages.length ? "Image attachments cannot be queued while the agent is running" : "Interrupt the current run and inject this message now"}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 5,
|
|
padding: "7px 12px",
|
|
background: canQueueStreamingMessage ? "rgba(234,179,8,0.12)" : "none",
|
|
border: "1px solid rgba(234,179,8,0.35)",
|
|
borderRadius: 8,
|
|
color: canQueueStreamingMessage ? "rgba(180,130,0,1)" : "var(--text-dim)",
|
|
cursor: canQueueStreamingMessage ? "pointer" : "not-allowed",
|
|
fontSize: 13, fontWeight: 600, letterSpacing: "-0.01em",
|
|
transition: "background 0.12s",
|
|
}}
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M5 1 L9 5 L5 9" /><line x1="1" y1="5" x2="9" y2="5" />
|
|
</svg>
|
|
{t("chat.steer")}
|
|
</button>
|
|
)}
|
|
{onFollowUp && (
|
|
<button
|
|
onClick={() => sendQueued("followup")}
|
|
disabled={!canQueueStreamingMessage}
|
|
title={attachedImages.length ? "Image attachments cannot be queued while the agent is running" : "Queue this message after the agent finishes"}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 5,
|
|
padding: "7px 12px",
|
|
background: canQueueStreamingMessage ? "rgba(129,140,248,0.12)" : "none",
|
|
border: "1px solid rgba(129,140,248,0.35)",
|
|
borderRadius: 8,
|
|
color: canQueueStreamingMessage ? "rgba(99,102,241,1)" : "var(--text-dim)",
|
|
cursor: canQueueStreamingMessage ? "pointer" : "not-allowed",
|
|
fontSize: 13, fontWeight: 600, letterSpacing: "-0.01em",
|
|
transition: "background 0.12s",
|
|
}}
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="5" y1="1" x2="5" y2="6" /><polyline points="2.5 3.5 5 1 7.5 3.5" />
|
|
<line x1="2" y1="9" x2="8" y2="9" />
|
|
</svg>
|
|
{t("chat.followUp")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!value.trim() && !attachedImages.length}
|
|
style={{
|
|
flexShrink: 0,
|
|
alignSelf: "flex-end",
|
|
display: "flex", alignItems: "center", gap: 6,
|
|
padding: "7px 14px",
|
|
background: (value.trim() || attachedImages.length) ? "var(--accent)" : "var(--bg-panel)",
|
|
border: "none",
|
|
borderRadius: 8,
|
|
color: (value.trim() || attachedImages.length) ? "#fff" : "var(--text-dim)",
|
|
cursor: (value.trim() || attachedImages.length) ? "pointer" : "not-allowed",
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
letterSpacing: "-0.01em",
|
|
boxShadow: (value.trim() || attachedImages.length) ? "0 1px 3px rgba(37,99,235,0.25)" : "none",
|
|
transition: "background 0.15s, box-shadow 0.15s",
|
|
}}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="2" y1="7" x2="11" y2="7" />
|
|
<polyline points="7.5 3 12 7 7.5 11" />
|
|
</svg>
|
|
{t("chat.send")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bash mode status label */}
|
|
{bashMode && (
|
|
<div className="text-xs px-2 py-1" style={{ color: bashExcluded ? "var(--text-muted)" : "var(--accent)", marginTop: 4 }}>
|
|
{t("chat.shell")} · {bashExcluded ? t("chat.outputLocal") : t("chat.outputModel")}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bottom bar: left | center (context) | right */}
|
|
<div style={{
|
|
marginTop: 8,
|
|
display: isMobile ? "grid" : "flex",
|
|
gridTemplateColumns: isMobile ? "minmax(0, 1fr) auto" : undefined,
|
|
alignItems: "center",
|
|
gap: 6,
|
|
}}>
|
|
|
|
{/* LEFT: attach + model selector (idle) or steer/followup toggle (streaming) */}
|
|
<div style={{ flex: isMobile ? "1 1 auto" : "0 0 auto", minWidth: 0, display: "flex", alignItems: "center", gap: 2 }}>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isStreaming}
|
|
title={t("chat.attachImage")}
|
|
style={{
|
|
flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: 32, height: 32, padding: 0,
|
|
background: "none", border: "none",
|
|
borderRadius: 9,
|
|
color: attachedImages.length ? "var(--accent)" : "var(--text-muted)",
|
|
cursor: isStreaming ? "not-allowed" : "pointer",
|
|
opacity: isStreaming ? 0.5 : 1,
|
|
transition: "background 0.12s, color 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (isStreaming) return;
|
|
e.currentTarget.style.background = "var(--bg-hover)";
|
|
e.currentTarget.style.color = attachedImages.length ? "var(--accent)" : "var(--text)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = "none";
|
|
e.currentTarget.style.color = attachedImages.length ? "var(--accent)" : "var(--text-muted)";
|
|
}}
|
|
>
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
<polyline points="21 15 16 10 5 21" />
|
|
</svg>
|
|
</button>
|
|
{/* Model selector — visible always, disabled during streaming */}
|
|
{(modelOptions.length > 0 || currentName || modelError) && onModelChange && (
|
|
<div ref={dropdownRef} style={{ position: "relative", flex: isMobile ? "1 1 auto" : undefined, minWidth: 0 }}>
|
|
<button
|
|
onClick={(e) => {
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
setModelDropdownRect({ top: rect.top, left: rect.left, width: rect.width });
|
|
setModelDropdownOpen((open) => {
|
|
if (open) setModelFilter("");
|
|
return !open;
|
|
});
|
|
}}
|
|
disabled={isStreaming}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 6,
|
|
justifyContent: isMobile ? "flex-start" : undefined,
|
|
padding: isMobile ? "8px 10px" : "8px 12px",
|
|
height: 32,
|
|
width: isMobile ? "100%" : undefined,
|
|
maxWidth: isMobile ? "100%" : 220,
|
|
overflow: "hidden",
|
|
background: modelDropdownOpen ? "var(--bg-hover)" : "none",
|
|
border: "none",
|
|
borderRadius: 9,
|
|
color: "var(--text-muted)",
|
|
cursor: isStreaming ? "not-allowed" : "pointer",
|
|
fontSize: 12,
|
|
opacity: isStreaming ? 0.5 : 1,
|
|
transition: "background 0.12s, color 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (isStreaming) return;
|
|
e.currentTarget.style.background = "var(--bg-hover)";
|
|
e.currentTarget.style.color = "var(--text)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = modelDropdownOpen ? "var(--bg-hover)" : "none";
|
|
e.currentTarget.style.color = "var(--text-muted)";
|
|
}}
|
|
title={modelOptions.length > 0 ? "Change model" : "No available models"}
|
|
>
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="4" y="4" width="16" height="16" rx="2" />
|
|
<rect x="9" y="9" width="6" height="6" />
|
|
<line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" />
|
|
<line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" />
|
|
<line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" />
|
|
<line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
|
|
</svg>
|
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}>
|
|
{currentName ?? (modelOptions.length > 0 ? "Select model" : "No models")}
|
|
</span>
|
|
</button>
|
|
{modelDropdownOpen && modelDropdownRect && (() => {
|
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
|
const bottom = viewportHeight - modelDropdownRect.top + 6;
|
|
const maxH = Math.max(120, Math.min(modelDropdownRect.top - 8, viewportHeight * 0.6));
|
|
// On mobile, pin to a small left margin and cap width to the
|
|
// viewport so long model names never push the panel off-screen.
|
|
const panelPos: React.CSSProperties = isMobile
|
|
? { left: 8, right: 8, maxWidth: "calc(100vw - 16px)" }
|
|
: { left: modelDropdownRect.left, width: "max-content", minWidth: modelDropdownRect.width };
|
|
return (
|
|
<div ref={modelDropdownPanelRef} style={{
|
|
position: "fixed",
|
|
bottom,
|
|
...panelPos,
|
|
zIndex: 500, background: "var(--bg)", border: "1px solid var(--border)",
|
|
borderRadius: 8, boxShadow: "0 -4px 16px rgba(0,0,0,0.10)",
|
|
overflow: "hidden", maxHeight: maxH, display: "flex", flexDirection: "column",
|
|
}}>
|
|
{showModelFilter && (
|
|
<div style={{ padding: "6px 8px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
|
|
<input
|
|
value={modelFilter}
|
|
onChange={(e) => setModelFilter(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") {
|
|
setModelFilter("");
|
|
setModelDropdownOpen(false);
|
|
}
|
|
}}
|
|
placeholder={t("chat.filterModels")}
|
|
aria-label={t("chat.filterModels")}
|
|
autoFocus
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
style={{
|
|
width: "100%",
|
|
minWidth: isMobile ? 0 : 220,
|
|
fontSize: 11,
|
|
fontFamily: "var(--font-mono)",
|
|
padding: "5px 8px",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 5,
|
|
outline: "none",
|
|
background: "var(--bg)",
|
|
color: "var(--text)",
|
|
boxSizing: "border-box",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div style={{ minHeight: 0, overflowY: "auto" }}>
|
|
{modelsByProvider.length === 0 ? (
|
|
<div style={{ padding: "8px 12px", color: "var(--text-dim)", fontSize: 12, whiteSpace: "nowrap" }}>
|
|
{modelFilter.trim() ? t("chat.noMatchingModels") : "No available models"}
|
|
</div>
|
|
) : modelsByProvider.map((group, gi) => (
|
|
<div key={group.provider}>
|
|
{(modelsByProvider.length > 1) && (
|
|
<div style={{
|
|
padding: "6px 12px 4px",
|
|
fontSize: 10, fontWeight: 600, color: "var(--text-dim)",
|
|
textTransform: "uppercase", letterSpacing: "0.07em",
|
|
borderTop: gi > 0 ? "1px solid var(--border)" : "none",
|
|
}}>
|
|
{group.provider}
|
|
</div>
|
|
)}
|
|
{group.options.map((opt) => {
|
|
const isActive = opt.modelId === model?.modelId && opt.provider === model?.provider;
|
|
return (
|
|
<button
|
|
key={`${opt.provider}:${opt.modelId}`}
|
|
onClick={() => {
|
|
setModelDropdownOpen(false);
|
|
setModelFilter("");
|
|
if (!isActive || isAutoModelSelection) onModelChange(opt.provider, opt.modelId);
|
|
}}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
width: "100%", padding: "7px 12px",
|
|
background: isActive ? "var(--bg-selected)" : "none",
|
|
border: "none",
|
|
color: isActive ? "var(--text)" : "var(--text-muted)",
|
|
cursor: "pointer", fontSize: 12, textAlign: "left",
|
|
fontWeight: isActive ? 600 : 400,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "var(--bg-hover)"; }}
|
|
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "none"; }}
|
|
>
|
|
{isActive
|
|
? <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="1.5 5 4 7.5 8.5 2.5" /></svg>
|
|
: <span style={{ width: 10, flexShrink: 0 }} />}
|
|
{opt.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* spacer */}
|
|
{!isMobile && <div style={{ flex: 1 }} />}
|
|
|
|
{/* RIGHT: thinking + tools preset + compact + sound (idle) | Stop + sound (streaming) */}
|
|
<div ref={controlsMenuRef} style={{
|
|
flex: "0 0 auto",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "flex-end",
|
|
position: "relative",
|
|
marginLeft: isMobile ? 0 : "auto",
|
|
}}>
|
|
{isMobile && (
|
|
<button
|
|
type="button"
|
|
title={controlsMenuOpen ? undefined : t("chat.moreControls")}
|
|
aria-label={t("chat.moreControls")}
|
|
aria-expanded={controlsMenuOpen}
|
|
aria-hidden={controlsMenuOpen || undefined}
|
|
tabIndex={controlsMenuOpen ? -1 : undefined}
|
|
onClick={() => {
|
|
setModelDropdownOpen(false);
|
|
setModelFilter("");
|
|
setControlsMenuOpen(true);
|
|
}}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: "100%",
|
|
height: 32,
|
|
padding: "8px 10px",
|
|
background: "none",
|
|
border: "none",
|
|
borderRadius: 9,
|
|
color: "var(--text-muted)",
|
|
cursor: controlsMenuOpen ? "default" : "pointer",
|
|
fontSize: 12,
|
|
fontWeight: 500,
|
|
visibility: controlsMenuOpen ? "hidden" : "visible",
|
|
pointerEvents: controlsMenuOpen ? "none" : "auto",
|
|
transition: "background 0.12s, color 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (controlsMenuOpen) return;
|
|
e.currentTarget.style.background = "var(--bg-hover)";
|
|
e.currentTarget.style.color = "var(--text)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (controlsMenuOpen) return;
|
|
e.currentTarget.style.background = "none";
|
|
e.currentTarget.style.color = "var(--text-muted)";
|
|
}}
|
|
>
|
|
{t("chat.moreControls")}
|
|
</button>
|
|
)}
|
|
<div style={{
|
|
display: isMobile ? (controlsMenuOpen ? "flex" : "none") : "flex",
|
|
alignItems: "center",
|
|
gap: isMobile ? 1 : 2,
|
|
...(isMobile ? {
|
|
position: "absolute",
|
|
right: 0,
|
|
bottom: 0,
|
|
zIndex: 60,
|
|
padding: 1,
|
|
width: "max-content",
|
|
maxWidth: "calc(100vw - 32px)",
|
|
flexWrap: "nowrap",
|
|
justifyContent: "flex-end",
|
|
border: "1px solid color-mix(in srgb, var(--border) 72%, transparent)",
|
|
borderRadius: 10,
|
|
background: "color-mix(in srgb, var(--bg-panel) 92%, var(--bg))",
|
|
boxShadow: "0 8px 24px rgba(0,0,0,0.14)",
|
|
backdropFilter: "blur(10px)",
|
|
} : null),
|
|
}}>
|
|
{!isStreaming && onThinkingLevelChange && (
|
|
<div ref={thinkingDropdownRef} style={{ position: "relative" }}>
|
|
<button
|
|
onClick={() => !isStreaming && setThinkingDropdownOpen((v) => !v)}
|
|
disabled={isStreaming}
|
|
title={t("chat.changeReasoning", { level: thinkingDisplayLabel })}
|
|
aria-label={t("chat.changeReasoningLabel")}
|
|
style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
|
|
padding: isMobile ? "0 6px" : "8px 12px",
|
|
width: isMobile ? "auto" : undefined,
|
|
height: 32,
|
|
background: thinkingDropdownOpen ? "var(--bg-hover)" : "none",
|
|
border: "none",
|
|
borderRadius: 9,
|
|
color: "var(--text-muted)",
|
|
cursor: isStreaming ? "not-allowed" : "pointer",
|
|
fontSize: 12,
|
|
opacity: isStreaming ? 0.5 : 1,
|
|
transition: "background 0.12s, color 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (isStreaming) return;
|
|
e.currentTarget.style.background = "var(--bg-hover)";
|
|
e.currentTarget.style.color = "var(--text)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = thinkingDropdownOpen ? "var(--bg-hover)" : "none";
|
|
e.currentTarget.style.color = "var(--text-muted)";
|
|
}}
|
|
>
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M9.5 2A5.5 5.5 0 0 0 4 7.5c0 1.7.78 3.21 2 4.21V14a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1v-2.29c1.22-1 2-2.51 2-4.21A5.5 5.5 0 0 0 9.5 2z" />
|
|
<line x1="7" y1="18" x2="12" y2="18" />
|
|
<line x1="8" y1="21" x2="11" y2="21" />
|
|
</svg>
|
|
{(!isMobile || controlsMenuOpen) && <span style={{ whiteSpace: "nowrap" }}>{thinkingDisplayLabel}</span>}
|
|
</button>
|
|
{thinkingDropdownOpen && (
|
|
<div style={{
|
|
position: "absolute", bottom: "calc(100% + 6px)", right: 0,
|
|
zIndex: 100, background: "var(--bg)", border: "1px solid var(--border)",
|
|
borderRadius: 8, boxShadow: "0 -4px 16px rgba(0,0,0,0.10)",
|
|
overflow: "hidden", minWidth: 180,
|
|
}}>
|
|
{THINKING_LEVELS.filter((lvl) => {
|
|
if (!availableThinkingLevels) return true;
|
|
if (lvl === "auto") return true;
|
|
return availableThinkingLevels.includes(lvl);
|
|
}).map((lvl) => {
|
|
const isActive = (thinkingLevel ?? "auto") === lvl;
|
|
const desc = t(THINKING_LEVEL_DESC_KEYS[lvl]);
|
|
const mappedVal = (lvl !== "auto" && thinkingLevelMap) ? thinkingLevelMap[lvl] : undefined;
|
|
const displayLabel = (mappedVal != null && mappedVal !== lvl) ? mappedVal : lvl;
|
|
const showOriginal = mappedVal != null && mappedVal !== lvl;
|
|
return (
|
|
<button
|
|
key={lvl}
|
|
onClick={() => { setThinkingDropdownOpen(false); if (!isActive) onThinkingLevelChange(lvl); }}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
width: "100%", padding: "7px 12px",
|
|
background: isActive ? "var(--bg-selected)" : "none",
|
|
border: "none",
|
|
color: isActive ? "var(--text)" : "var(--text-muted)",
|
|
cursor: "pointer", fontSize: 12, textAlign: "left",
|
|
fontWeight: isActive ? 600 : 400,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "var(--bg-hover)"; }}
|
|
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "none"; }}
|
|
>
|
|
{isActive
|
|
? <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="1.5 5 4 7.5 8.5 2.5" /></svg>
|
|
: <span style={{ width: 10, flexShrink: 0 }} />}
|
|
<span style={{ flex: 1 }}>
|
|
{displayLabel}
|
|
{showOriginal && <span style={{ fontSize: 10, color: "var(--text-dim)", fontFamily: "var(--font-mono)", marginLeft: 5 }}>({lvl})</span>}
|
|
</span>
|
|
<span style={{ fontSize: 11, color: "var(--text-dim)", marginLeft: 8 }}>{desc}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!isStreaming && onToolPresetChange && (
|
|
<div ref={toolDropdownRef} style={{ position: "relative" }}>
|
|
<button
|
|
onClick={() => !isStreaming && setToolDropdownOpen((v) => !v)}
|
|
disabled={isStreaming}
|
|
title={t("chat.changeToolPreset") + `: ${toolPresetLabel}`}
|
|
aria-label={t("chat.changeToolPreset")}
|
|
style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
|
|
padding: isMobile ? "0 6px" : "8px 12px",
|
|
width: isMobile ? "auto" : undefined,
|
|
height: 32,
|
|
background: toolDropdownOpen ? "var(--bg-hover)" : "none",
|
|
border: "none",
|
|
borderRadius: 9,
|
|
color: "var(--text-muted)",
|
|
cursor: isStreaming ? "not-allowed" : "pointer",
|
|
fontSize: 12,
|
|
opacity: isStreaming ? 0.5 : 1,
|
|
transition: "background 0.12s, color 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (isStreaming) return;
|
|
e.currentTarget.style.background = "var(--bg-hover)";
|
|
e.currentTarget.style.color = "var(--text)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = toolDropdownOpen ? "var(--bg-hover)" : "none";
|
|
e.currentTarget.style.color = "var(--text-muted)";
|
|
}}
|
|
>
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
|
</svg>
|
|
{(!isMobile || controlsMenuOpen) && <span style={{ whiteSpace: "nowrap" }}>{toolPresetLabel}</span>}
|
|
</button>
|
|
{toolDropdownOpen && (
|
|
<div style={{
|
|
position: "absolute", bottom: "calc(100% + 6px)", right: 0,
|
|
zIndex: 100, background: "var(--bg)", border: "1px solid var(--border)",
|
|
borderRadius: 8, boxShadow: "0 -4px 16px rgba(0,0,0,0.10)",
|
|
overflow: "hidden", minWidth: 120,
|
|
}}>
|
|
{TOOL_PRESETS.map((lvl) => {
|
|
const preset = TOOL_PRESET_MAP[lvl];
|
|
const isActive = (toolPreset ?? "default") === preset;
|
|
const desc = lvl === "off" ? t("chat.noTools") : lvl === "default" ? t("chat.builtInTools", { count: 4 }) : t("chat.allBuiltInTools");
|
|
return (
|
|
<button
|
|
key={lvl}
|
|
onClick={() => { setToolDropdownOpen(false); if (!isActive) onToolPresetChange(preset); }}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
width: "100%", padding: "7px 12px",
|
|
background: isActive ? "var(--bg-selected)" : "none",
|
|
border: "none",
|
|
color: isActive ? "var(--text)" : "var(--text-muted)",
|
|
cursor: "pointer", fontSize: 12, textAlign: "left",
|
|
fontWeight: isActive ? 600 : 400,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "var(--bg-hover)"; }}
|
|
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "none"; }}
|
|
>
|
|
{isActive
|
|
? <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="1.5 5 4 7.5 8.5 2.5" /></svg>
|
|
: <span style={{ width: 10, flexShrink: 0 }} />}
|
|
<span style={{ flex: 1 }}>{lvl}</span>
|
|
<span style={{ fontSize: 11, color: "var(--text-dim)", marginLeft: 8 }}>{desc}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isStreaming && onCompact && (
|
|
<div>
|
|
<button
|
|
onClick={isCompacting ? onAbortCompaction : onCompact}
|
|
disabled={isStreaming && !isCompacting}
|
|
style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
|
|
padding: isMobile ? "0 6px" : "8px 12px",
|
|
width: isMobile ? "auto" : undefined,
|
|
height: 32,
|
|
background: isCompacting ? "rgba(239,68,68,0.08)" : "none",
|
|
border: "none",
|
|
borderRadius: 9,
|
|
color: isCompacting ? "#ef4444" : "var(--text-muted)",
|
|
cursor: (isStreaming && !isCompacting) ? "not-allowed" : "pointer",
|
|
fontSize: 12, opacity: (isStreaming && !isCompacting) ? 0.5 : 1,
|
|
transition: "background 0.12s, color 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (isStreaming && !isCompacting) return;
|
|
e.currentTarget.style.background = isCompacting ? "rgba(239,68,68,0.16)" : "var(--bg-hover)";
|
|
e.currentTarget.style.color = isCompacting ? "#ef4444" : "var(--text)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = isCompacting ? "rgba(239,68,68,0.08)" : "none";
|
|
e.currentTarget.style.color = isCompacting ? "#ef4444" : "var(--text-muted)";
|
|
}}
|
|
title={isCompacting ? t("chat.stopCompaction") : t("chat.compactContext")}
|
|
aria-label={isCompacting ? t("chat.stopCompaction") : t("chat.compactContext")}
|
|
>
|
|
{isCompacting ? (
|
|
<><svg width="10" height="10" viewBox="0 0 10 10" fill="none"><rect x="2" y="2" width="6" height="6" rx="1" fill="currentColor" /></svg>{(!isMobile || controlsMenuOpen) && <span style={{ whiteSpace: "nowrap" }}>{t("chat.compacting")}</span>}</>
|
|
) : (
|
|
<><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="4 14 10 14 10 20" /><polyline points="20 10 14 10 14 4" />
|
|
<line x1="10" y1="14" x2="3" y2="21" /><line x1="21" y1="3" x2="14" y2="10" />
|
|
</svg>{(!isMobile || controlsMenuOpen) && <span style={{ whiteSpace: "nowrap" }}>{t("chat.compact")}</span>}</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isStreaming && (
|
|
<button
|
|
onClick={onAbort}
|
|
title={t("chat.stopAgent")}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 6,
|
|
padding: "8px 14px",
|
|
height: 32,
|
|
background: "rgba(239,68,68,0.08)",
|
|
border: "1px solid rgba(239,68,68,0.3)",
|
|
borderRadius: 9,
|
|
color: "#ef4444",
|
|
cursor: "pointer",
|
|
fontSize: 12, fontWeight: 600,
|
|
whiteSpace: "nowrap", letterSpacing: "-0.01em",
|
|
transition: "background 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(239,68,68,0.16)"; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(239,68,68,0.08)"; }}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
<rect x="1.5" y="1.5" width="7" height="7" rx="1.5" fill="currentColor" />
|
|
</svg>
|
|
{t("chat.stop")}
|
|
</button>
|
|
)}
|
|
|
|
{onSoundToggle !== undefined && (
|
|
<button
|
|
onClick={onSoundToggle}
|
|
title={soundEnabled ? t("chat.disableSound") : t("chat.enableSound")}
|
|
aria-label={soundEnabled ? t("chat.disableSound") : t("chat.enableSound")}
|
|
style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
|
|
width: isMobile ? 32 : 32,
|
|
height: 32,
|
|
padding: 0,
|
|
background: "none",
|
|
border: "none",
|
|
borderRadius: 9,
|
|
color: soundEnabled ? "var(--text-muted)" : "var(--text-dim)",
|
|
cursor: "pointer",
|
|
opacity: soundEnabled ? 1 : 0.55,
|
|
transition: "background 0.12s, color 0.12s, opacity 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = "var(--bg-hover)";
|
|
e.currentTarget.style.color = "var(--text)";
|
|
e.currentTarget.style.opacity = "1";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = "none";
|
|
e.currentTarget.style.color = soundEnabled ? "var(--text-muted)" : "var(--text-dim)";
|
|
e.currentTarget.style.opacity = soundEnabled ? "1" : "0.55";
|
|
}}
|
|
>
|
|
{soundEnabled ? (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
</svg>
|
|
) : (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
<line x1="23" y1="9" x2="17" y2="15" />
|
|
<line x1="17" y1="9" x2="23" y2="15" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
)}
|
|
{isMobile && controlsMenuOpen && (
|
|
<button
|
|
type="button"
|
|
title={t("chat.collapseControls")}
|
|
aria-label={t("chat.collapseControls")}
|
|
aria-expanded={true}
|
|
onClick={() => {
|
|
setToolDropdownOpen(false);
|
|
setThinkingDropdownOpen(false);
|
|
setControlsMenuOpen(false);
|
|
}}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 36,
|
|
height: 32,
|
|
padding: 0,
|
|
marginLeft: 0,
|
|
background: "var(--bg-hover)",
|
|
border: "none",
|
|
borderLeft: "1px solid color-mix(in srgb, var(--border) 72%, transparent)",
|
|
borderRadius: "0 9px 9px 0",
|
|
color: "var(--text)",
|
|
cursor: "pointer",
|
|
transition: "background 0.12s, color 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = "var(--bg-selected)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = "var(--bg-hover)";
|
|
}}
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|