Files
pi-agent-integrated/pi-web/components/ChatInput.tsx
T
luckyyzh ccb038988e feat: 识图提速(模型常驻/精简模板/磁盘缓存)+ WebUI 上传压缩 + 缓存清理脚本
- vision.ts: keep_alive:-1 常驻显存(治冷加载 7.7GB);转录缓存持久化
  data/agent/vision-cache.json(重启后历史图秒回);hook 转录用精简模板
- ChatInput/image-attachments: 上传 JPEG 长边>1600px 自动压缩 q0.85
- scripts/cleanup-cache.mjs: npm/opengrep 缓存、过期会话、旧图瘦身清理
- README 双语同步
2026-08-03 06:33:43 +08:00

2276 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,
compressImageFile,
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(async (file) => {
// 大 JPEG 先压缩(长边 1600/q0.85),会话不再内嵌数 MB base64,加载/传输/转录都更快
const compressed = await compressImageFile(file);
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve((reader.result as string).split(",")[1]);
reader.onerror = reject;
reader.readAsDataURL(compressed);
});
return {
data: base64,
mimeType: compressed.type,
previewUrl: URL.createObjectURL(compressed),
};
})
);
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>
);
});