feat(chat): add slash command menu and context
This commit is contained in:
parent
44944efd9b
commit
dfc89cbbaa
@ -4,6 +4,7 @@ import { Plus, Trash2, MessageSquare, Loader2, Search, Edit2, X } from "lucide-r
|
||||
import { useConversationsQuery, useCreateConversationMutation, useDeleteConversationMutation } from "@/hooks/useAiChatQuery";
|
||||
import { useChatPage } from "./ChatPageContext";
|
||||
import type { ConversationResponse } from "@/client/model";
|
||||
import { t } from "@/i18n/T";
|
||||
|
||||
interface ChatConversationListProps {
|
||||
selectedId: string | null;
|
||||
@ -139,14 +140,14 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
style={{ borderBottom: "1px solid var(--border-subtle)" }}
|
||||
>
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
Chat History
|
||||
{t("chat.conversations.chat_history")}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
title="Search (Ctrl+K)"
|
||||
title={t("navigation.search_shortcut")}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@ -155,7 +156,7 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
disabled={createMutation.isPending}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
title="New chat"
|
||||
title={t("chat.header.new_chat")}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
@ -180,7 +181,7 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
placeholder={t("chat.conversations.search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 text-sm bg-transparent outline-none min-w-0"
|
||||
@ -220,7 +221,7 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Chat
|
||||
{t("chat.conversations.new_chat")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -248,13 +249,13 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
className="text-[13px] font-medium text-center"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{searchQuery ? "No matching conversations" : "No conversations yet"}
|
||||
{searchQuery ? t("chat.conversations.no_matching") : t("chat.conversations.no_conversations")}
|
||||
</p>
|
||||
<p
|
||||
className="text-[12px] text-center leading-relaxed"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{searchQuery ? "Try a different search term." : "Start a new chat to begin exploring with AI."}
|
||||
{searchQuery ? t("chat.conversations.try_different_search") : t("chat.conversations.start_new_chat")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -337,7 +338,7 @@ function ConversationItem({
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] truncate leading-snug">
|
||||
{conversation.title || "Untitled Chat"}
|
||||
{conversation.title || t("chat.conversations.untitled_chat")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {Loader2, MoreHorizontal, Pencil, PanelLeftOpen, PanelLeftClose, Share2} from "lucide-react";
|
||||
import {useConversationQuery} from "@/hooks/useAiChatQuery";
|
||||
import {t} from "@/i18n/T";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversationId: string | null;
|
||||
@ -11,7 +12,7 @@ interface ChatHeaderProps {
|
||||
export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onToggleSidebar}: ChatHeaderProps) {
|
||||
const {data: conversation} = useConversationQuery(conversationId || "");
|
||||
|
||||
const title = conversation?.title || "New Chat";
|
||||
const title = conversation?.title || t("chat.header.new_chat");
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -26,7 +27,7 @@ export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onT
|
||||
onClick={onToggleSidebar}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||
style={{color: "var(--text-muted)"}}
|
||||
title={isSidebarCollapsed ? "Expand Chat History" : "Collapse Chat History"}
|
||||
title={isSidebarCollapsed ? t("chat.header.expand_history") : t("chat.header.collapse_history")}
|
||||
>
|
||||
{isSidebarCollapsed ? <PanelLeftOpen className="w-4 h-4"/> : <PanelLeftClose className="w-4 h-4"/>}
|
||||
</button>
|
||||
@ -34,7 +35,7 @@ export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onT
|
||||
className="text-sm font-medium truncate"
|
||||
style={{color: "var(--text-primary)"}}
|
||||
>
|
||||
{conversationId ? title : "Chat"}
|
||||
{conversationId ? title : t("search.chat")}
|
||||
</h2>
|
||||
{isStreaming && (
|
||||
<span
|
||||
@ -42,7 +43,7 @@ export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onT
|
||||
style={{color: "var(--accent)"}}
|
||||
>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin"/>
|
||||
<span className="hidden sm:inline">Streaming...</span>
|
||||
<span className="hidden sm:inline">{t("chat.header.streaming")}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -53,21 +54,21 @@ export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onT
|
||||
<button
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||
style={{color: "var(--text-muted)"}}
|
||||
title="Share conversation"
|
||||
title={t("chat.header.share")}
|
||||
>
|
||||
<Share2 className="w-[14px] h-[14px]"/>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||
style={{color: "var(--text-muted)"}}
|
||||
title="Rename"
|
||||
title={t("chat.header.rename")}
|
||||
>
|
||||
<Pencil className="w-[14px] h-[14px]"/>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||
style={{color: "var(--text-muted)"}}
|
||||
title="More"
|
||||
title={t("chat.header.more")}
|
||||
>
|
||||
<MoreHorizontal className="w-[14px] h-[14px]"/>
|
||||
</button>
|
||||
|
||||
@ -1,4 +1,13 @@
|
||||
import { Copy, Check, Sparkles, ClipboardList, Pencil, RefreshCw, GitFork } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Sparkles,
|
||||
ClipboardList,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
GitFork,
|
||||
FolderGit2,
|
||||
} from "lucide-react";
|
||||
import { memo, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
||||
@ -18,7 +27,9 @@ import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-e
|
||||
import type { MessageResponse } from "@/hooks/useAiChatQuery";
|
||||
import { getModelIcon } from "@/lib/icons/modelIcons";
|
||||
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useChatPage } from "./ChatPageContext";
|
||||
import { parseSlashContextMetadata } from "./chatSlashContext";
|
||||
|
||||
interface ChatMessageBubbleProps {
|
||||
message: MessageResponse;
|
||||
@ -60,9 +71,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
|
||||
// Parse content into IrContentBlock[] (handles both old and future formats)
|
||||
const blocks: IrContentBlock[] = useMemo(() =>
|
||||
isUser
|
||||
? [{ role: "user", nodes: [] }]
|
||||
: parseContentBlocks(message.content),
|
||||
isUser ? [] : parseContentBlocks(message.content),
|
||||
[isUser, message.content]
|
||||
);
|
||||
|
||||
@ -74,6 +83,10 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
: "";
|
||||
const plainText = isUser ? userText : extractAnswerText(blocks);
|
||||
const hasThinking = blocks.some((b) => b.role === "thinking");
|
||||
const selectedContexts = useMemo(
|
||||
() => parseSlashContextMetadata(message.metadata),
|
||||
[message.metadata]
|
||||
);
|
||||
|
||||
// Fetch versions when showing version switcher
|
||||
const versionsQuery = useMessageVersionsQuery(conversationId, message.id);
|
||||
@ -194,6 +207,28 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
|
||||
{/* Interleaved blocks — thinking (collapsible) + answer (IrRenderer) */}
|
||||
<div className="text-sm" style={{ color: "var(--text-primary)" }}>
|
||||
{isUser && selectedContexts.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{selectedContexts.map((context) => (
|
||||
<Badge
|
||||
key={`${context.kind}:${context.id}`}
|
||||
variant="outline"
|
||||
className="h-auto gap-1 rounded-full px-2.5 py-1"
|
||||
>
|
||||
{context.kind === "repo" ? (
|
||||
<FolderGit2 className="h-3 w-3" />
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3" />
|
||||
)}
|
||||
<span>{context.label}</span>
|
||||
<span className="text-[10px] uppercase opacity-70">
|
||||
{context.kind}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUser && isEditing ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
@ -260,13 +295,14 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
if (b.role === "tool_call") {
|
||||
// Tool call visualization
|
||||
const toolCallNode = b.nodes.find((n) => n.type === "tool_call") as IrToolCallNode | undefined;
|
||||
if (toolCallNode) {
|
||||
if (toolCallNode && toolCallNode.tool === "call_sub_agent") {
|
||||
return (
|
||||
<ToolCallBlock
|
||||
key={i}
|
||||
toolName={toolCallNode.tool}
|
||||
args={toolCallNode.args}
|
||||
status="ok"
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -274,14 +310,17 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
}
|
||||
if (b.role === "tool_result") {
|
||||
const toolResultNode = b.nodes.find((n) => n.type === "tool_result") as IrToolResultNode | undefined;
|
||||
if (toolResultNode) {
|
||||
if (toolResultNode && toolResultNode.tool === "call_sub_agent") {
|
||||
return (
|
||||
<ToolCallBlock
|
||||
key={i}
|
||||
toolName={toolResultNode.tool}
|
||||
args={{}}
|
||||
args={{ role: toolResultNode.role, task: toolResultNode.task }}
|
||||
status={toolResultNode.status}
|
||||
result={toolResultNode.content}
|
||||
childrenId={toolResultNode.children_id}
|
||||
subAgentOutput={toolResultNode.content}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { AlertCircle, FolderGit2, Sparkles, X } from "lucide-react";
|
||||
import {
|
||||
useCreateMessageMutation,
|
||||
useCreateConversationMutation,
|
||||
@ -17,6 +17,18 @@ import {
|
||||
import type { CreateMessageParams } from "@/client/model";
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ChatModelSelector } from "./ChatModelSelector";
|
||||
import { t } from "@/i18n/T";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useProjectReposQuery, useReposQuery } from "@/hooks/useReposQuery";
|
||||
import { useSkillsQuery } from "@/hooks/useSkillsQuery";
|
||||
import {
|
||||
ChatSlashCommandMenu,
|
||||
type SlashCommandState,
|
||||
} from "./ChatSlashCommandMenu";
|
||||
import {
|
||||
buildSlashContextMetadata,
|
||||
type SelectedChatContext,
|
||||
} from "./chatSlashContext";
|
||||
|
||||
interface ChatMessageInputProps {
|
||||
conversationId: string | null;
|
||||
@ -25,6 +37,61 @@ interface ChatMessageInputProps {
|
||||
onSelectConversation: (id: string) => void;
|
||||
}
|
||||
|
||||
interface ActiveSlashState extends SlashCommandState {
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
}
|
||||
|
||||
function getTextarea(): HTMLTextAreaElement | null {
|
||||
return document.querySelector<HTMLTextAreaElement>(
|
||||
"textarea[name='message']"
|
||||
);
|
||||
}
|
||||
|
||||
function focusTextareaAt(position: number) {
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = getTextarea();
|
||||
if (!textarea) return;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(position, position);
|
||||
});
|
||||
}
|
||||
|
||||
function getSlashState(
|
||||
value: string,
|
||||
caret: number
|
||||
): ActiveSlashState | null {
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const slashMatch = beforeCaret.match(/(?:^|\s)\/([^\n]*)$/);
|
||||
if (!slashMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slashIndex = beforeCaret.lastIndexOf("/");
|
||||
if (slashIndex < 0) return null;
|
||||
|
||||
return {
|
||||
query: (slashMatch[1] ?? "").trim().toLowerCase(),
|
||||
rangeStart: slashIndex,
|
||||
rangeEnd: caret,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAroundReplacement(
|
||||
before: string,
|
||||
replacement: string,
|
||||
after: string
|
||||
) {
|
||||
if (!replacement) {
|
||||
if (before.endsWith(" ") && after.startsWith(" ")) {
|
||||
return `${before}${after.slice(1)}`;
|
||||
}
|
||||
return `${before}${after}`;
|
||||
}
|
||||
|
||||
return `${before}${replacement}${after}`;
|
||||
}
|
||||
|
||||
export function ChatMessageInput({
|
||||
conversationId,
|
||||
isStreaming,
|
||||
@ -33,12 +100,134 @@ export function ChatMessageInput({
|
||||
}: ChatMessageInputProps) {
|
||||
const [showModelWarning, setShowModelWarning] = useState(false);
|
||||
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
|
||||
const [activeStreamConversationId, setActiveStreamConversationId] = useState<string | null>(null);
|
||||
const [activeStreamConversationId, setActiveStreamConversationId] =
|
||||
useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [caretPosition, setCaretPosition] = useState(0);
|
||||
const [selectedContexts, setSelectedContexts] = useState<
|
||||
SelectedChatContext[]
|
||||
>([]);
|
||||
const [activeSlashIndex, setActiveSlashIndex] = useState(0);
|
||||
|
||||
const createMessageMutation = useCreateMessageMutation();
|
||||
const createConversationMutation = useCreateConversationMutation();
|
||||
const stopMessageMutation = useStopMessageMutation();
|
||||
const { scope, projectId, selectedModel, setSelectedModel } = useChatPage();
|
||||
const { scope, projectId, projectName, selectedModel, setSelectedModel } =
|
||||
useChatPage();
|
||||
const runStream = useChatStreamRunner(setIsStreaming);
|
||||
const { data: projectRepos = [] } = useProjectReposQuery(projectName);
|
||||
const { data: personalRepos = [] } = useReposQuery();
|
||||
const { data: skills = [] } = useSkillsQuery(projectName);
|
||||
|
||||
const slashState = useMemo(
|
||||
() => getSlashState(draft, caretPosition),
|
||||
[draft, caretPosition]
|
||||
);
|
||||
|
||||
const repoOptions = useMemo(() => {
|
||||
const repos =
|
||||
scope === "project" && projectName ? projectRepos : personalRepos;
|
||||
|
||||
return repos.map((repo) => ({
|
||||
kind: "repo" as const,
|
||||
id: repo.uid,
|
||||
repo_name: repo.repo_name,
|
||||
label: repo.repo_name,
|
||||
description: repo.description ?? null,
|
||||
project_name:
|
||||
"project_name" in repo && typeof repo.project_name === "string"
|
||||
? repo.project_name
|
||||
: undefined,
|
||||
}));
|
||||
}, [scope, projectName, projectRepos, personalRepos]);
|
||||
|
||||
const skillOptions = useMemo(
|
||||
() =>
|
||||
skills.map((skill) => ({
|
||||
kind: "skill" as const,
|
||||
id: String(skill.id),
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
label: skill.name,
|
||||
description: skill.description ?? null,
|
||||
})),
|
||||
[skills]
|
||||
);
|
||||
|
||||
const allSlashItems = useMemo(
|
||||
() => [...repoOptions, ...skillOptions] satisfies SelectedChatContext[],
|
||||
[repoOptions, skillOptions]
|
||||
);
|
||||
|
||||
const slashItems = useMemo(() => {
|
||||
if (!slashState) {
|
||||
return [] as SelectedChatContext[];
|
||||
}
|
||||
|
||||
const query = slashState.query;
|
||||
|
||||
return allSlashItems
|
||||
.filter((item) => {
|
||||
if (!query) return true;
|
||||
|
||||
if (item.kind === "repo") {
|
||||
return [
|
||||
item.label,
|
||||
item.repo_name,
|
||||
item.project_name ?? "",
|
||||
item.description ?? "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
}
|
||||
|
||||
return [item.label, item.name, item.slug, item.description ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === "repo" ? -1 : 1;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [allSlashItems, slashState]);
|
||||
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(selectedContexts.map((item) => `${item.kind}:${item.id}`)),
|
||||
[selectedContexts]
|
||||
);
|
||||
|
||||
const isSlashMenuOpen =
|
||||
!!slashState;
|
||||
|
||||
const replaceSlashText = (state: ActiveSlashState, replacement: string) => {
|
||||
const before = draft.slice(0, state.rangeStart);
|
||||
const after = draft.slice(state.rangeEnd);
|
||||
const nextValue = mergeAroundReplacement(before, replacement, after);
|
||||
const nextCaret = before.length + replacement.length;
|
||||
|
||||
setDraft(nextValue);
|
||||
setCaretPosition(nextCaret);
|
||||
setActiveSlashIndex(0);
|
||||
focusTextareaAt(nextCaret);
|
||||
};
|
||||
|
||||
const handlePickSlashItem = (item: SelectedChatContext) => {
|
||||
if (!slashState) return;
|
||||
|
||||
setSelectedContexts((current) => {
|
||||
const exists = current.some(
|
||||
(context) => context.kind === item.kind && context.id === item.id
|
||||
);
|
||||
return exists ? current : [...current, item];
|
||||
});
|
||||
|
||||
replaceSlashText(slashState, "");
|
||||
};
|
||||
|
||||
const handleSubmit = async ({ text }: PromptInputMessage) => {
|
||||
if (!text.trim()) return;
|
||||
@ -77,7 +266,10 @@ export function ChatMessageInput({
|
||||
model: selectedModel.model_name,
|
||||
parent_message_id: null,
|
||||
is_fork_origin: false,
|
||||
metadata: {},
|
||||
metadata:
|
||||
selectedContexts.length > 0
|
||||
? buildSlashContextMetadata(selectedContexts)
|
||||
: {},
|
||||
room_id: null,
|
||||
};
|
||||
|
||||
@ -88,6 +280,10 @@ export function ChatMessageInput({
|
||||
|
||||
if (!messageResponse?.id) return;
|
||||
|
||||
setDraft("");
|
||||
setCaretPosition(0);
|
||||
setSelectedContexts([]);
|
||||
|
||||
try {
|
||||
setActiveMessageId(messageResponse.id);
|
||||
setActiveStreamConversationId(activeConversationId);
|
||||
@ -102,27 +298,139 @@ export function ChatMessageInput({
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!isSlashMenuOpen || !slashState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxIndex = Math.max(slashItems.length - 1, 0);
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveSlashIndex((current) => (current >= maxIndex ? 0 : current + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveSlashIndex((current) => (current <= 0 ? maxIndex : current - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.key === "Enter" || event.key === "Tab") && !event.shiftKey) {
|
||||
const item = slashItems[activeSlashIndex];
|
||||
if (item) {
|
||||
event.preventDefault();
|
||||
handlePickSlashItem(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
replaceSlashText(slashState, "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
<div className="max-w-3xl mx-auto relative">
|
||||
{/* Model warning */}
|
||||
<div
|
||||
className="shrink-0 px-4 pb-4"
|
||||
style={{ backgroundColor: "var(--surface-ground)" }}
|
||||
>
|
||||
<div className="relative mx-auto max-w-3xl">
|
||||
<ChatSlashCommandMenu
|
||||
open={isSlashMenuOpen}
|
||||
state={slashState}
|
||||
activeIndex={activeSlashIndex}
|
||||
items={slashItems}
|
||||
selectedKeys={selectedKeys}
|
||||
onPickItem={handlePickSlashItem}
|
||||
onHoverIndex={setActiveSlashIndex}
|
||||
/>
|
||||
|
||||
{showModelWarning && (
|
||||
<div
|
||||
className="absolute -top-10 left-0 right-0 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium animate-bounce"
|
||||
className="absolute -top-10 left-0 right-0 flex animate-bounce items-center justify-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--accent-muted)",
|
||||
color: "var(--accent)",
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
Please select a model first
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
{t("chat.model_selector.please_select_model")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea placeholder="Message" className="py-3" />
|
||||
{selectedContexts.length > 0 && (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5 px-3 pt-2 pb-1"
|
||||
>
|
||||
{selectedContexts.map((context) => (
|
||||
<Badge
|
||||
key={`${context.kind}:${context.id}`}
|
||||
variant="outline"
|
||||
className="h-auto gap-1 rounded-full px-2.5 py-1"
|
||||
>
|
||||
{context.kind === "repo" ? (
|
||||
<FolderGit2 className="h-3 w-3" />
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3" />
|
||||
)}
|
||||
<span>{context.label}</span>
|
||||
<span className="text-[10px] uppercase opacity-70">
|
||||
{context.kind}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-full opacity-70 transition-opacity hover:opacity-100"
|
||||
onClick={() =>
|
||||
setSelectedContexts((current) =>
|
||||
current.filter(
|
||||
(item) =>
|
||||
!(
|
||||
item.kind === context.kind &&
|
||||
item.id === context.id
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
aria-label={`Remove ${context.label}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<PromptInputTextarea
|
||||
placeholder={
|
||||
`${t("common.placeholders.message")} /`
|
||||
}
|
||||
className="py-3"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.currentTarget.value);
|
||||
setCaretPosition(
|
||||
event.currentTarget.selectionStart ??
|
||||
event.currentTarget.value.length
|
||||
);
|
||||
setActiveSlashIndex(0);
|
||||
}}
|
||||
onSelect={(event) => {
|
||||
setCaretPosition(event.currentTarget.selectionStart ?? 0);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
setCaretPosition(event.currentTarget.selectionStart ?? 0);
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
setCaretPosition(event.currentTarget.selectionStart ?? 0);
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
|
||||
<PromptInputFooter>
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
@ -133,7 +441,10 @@ export function ChatMessageInput({
|
||||
status={isStreaming ? "streaming" : "ready"}
|
||||
onStop={() => {
|
||||
if (activeStreamConversationId && activeMessageId) {
|
||||
stopMessageMutation.mutate({ conversationId: activeStreamConversationId, messageId: activeMessageId });
|
||||
stopMessageMutation.mutate({
|
||||
conversationId: activeStreamConversationId,
|
||||
messageId: activeMessageId,
|
||||
});
|
||||
}
|
||||
setIsStreaming(false);
|
||||
}}
|
||||
|
||||
@ -13,24 +13,25 @@ import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
|
||||
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
|
||||
import { useCodePreview } from "@/components/chat/CodePreviewContext";
|
||||
import { t } from "@/i18n/T";
|
||||
|
||||
interface ChatMessageListProps {
|
||||
conversationId: string | null;
|
||||
setIsStreaming: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const PROMPT_SUGGESTIONS = [
|
||||
{ icon: Code, text: "Explain a piece of code or debug an error" },
|
||||
{ icon: FileText, text: "Summarize or draft a document" },
|
||||
{ icon: GitPullRequest, text: "Review a pull request or repo" },
|
||||
{ icon: Brain, text: "Brainstorm ideas or outline a plan" },
|
||||
];
|
||||
|
||||
const OVERSCAN = 3;
|
||||
const ESTIMATED_SIZE = 200;
|
||||
|
||||
const PROSE_CLASS = "prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1";
|
||||
|
||||
const PROMPT_SUGGESTIONS = [
|
||||
{ icon: Code, text: t("chat.message_list.explain_code") },
|
||||
{ icon: FileText, text: t("chat.message_list.summarize_doc") },
|
||||
{ icon: GitPullRequest, text: t("chat.message_list.review_pr") },
|
||||
{ icon: Brain, text: t("chat.message_list.brainstorm") },
|
||||
];
|
||||
|
||||
export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageListProps) {
|
||||
const { data, isLoading } = useMessagesQuery(conversationId || "");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@ -148,10 +149,10 @@ export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageL
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-[28px] font-semibold mb-2 tracking-tight" style={{ color: "var(--text-primary)" }}>
|
||||
How can I help you today?
|
||||
{t("chat.message_list.welcome_title")}
|
||||
</h1>
|
||||
<p className="text-[15px]" style={{ color: "var(--text-muted)", lineHeight: "1.6" }}>
|
||||
Ask anything - I can help with code, writing, analysis, and much more.
|
||||
{t("chat.message_list.welcome_desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 w-full max-w-md">
|
||||
@ -213,7 +214,7 @@ export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageL
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shimmer duration={1}>New response</Shimmer>
|
||||
<Shimmer duration={1}>{t("chat.message_list.new_response")}</Shimmer>
|
||||
<ChevronDown className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
||||
</div>
|
||||
</div>
|
||||
@ -242,8 +243,8 @@ export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageL
|
||||
virtualizer.scrollToIndex(messageIndex, { align: "center", behavior: "smooth" });
|
||||
}}
|
||||
className="pointer-events-auto flex h-4 w-4 items-center justify-center rounded-full transition-all"
|
||||
title={`Jump to your message ${anchorIndex + 1}`}
|
||||
aria-label={`Jump to your message ${anchorIndex + 1}`}
|
||||
title={t("chat.message_list.jump_to_message", { index: anchorIndex + 1 })}
|
||||
aria-label={t("chat.message_list.jump_to_message", { index: anchorIndex + 1 })}
|
||||
>
|
||||
<span
|
||||
className="block rounded-full transition-all"
|
||||
@ -296,7 +297,7 @@ export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageL
|
||||
|
||||
{hasStreamingBubble && (
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<StreamingBubble parts={stream!.parts} isDone={stream!.isDone} />
|
||||
<StreamingBubble parts={stream!.parts} isDone={stream!.isDone} conversationId={conversationId}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -304,7 +305,7 @@ export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageL
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boolean }) {
|
||||
function StreamingBubble({ parts, isDone, conversationId }: { parts: StreamPart[]; isDone: boolean; conversationId: string }) {
|
||||
const { selectedModel } = useChatPage();
|
||||
const [displayParts, setDisplayParts] = useState<StreamPart[]>([]);
|
||||
const [displayDone, setDisplayDone] = useState(false);
|
||||
@ -359,7 +360,7 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
</span>
|
||||
{!displayDone && (
|
||||
<span className="text-[11px] animate-pulse" style={{ color: "var(--text-muted)" }}>
|
||||
responding...
|
||||
{t("chat.message_list.responding")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -376,16 +377,21 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
);
|
||||
}
|
||||
if (part.type === "tool_call") {
|
||||
if (part.toolName !== "call_sub_agent") return null;
|
||||
return (
|
||||
<ToolCallBlock
|
||||
key={i}
|
||||
toolName={part.toolName || "unknown"}
|
||||
args={part.toolArgs || {}}
|
||||
status={displayDone ? "ok" : "pending"}
|
||||
childrenId={part.children_id}
|
||||
subAgentOutput={part.subAgentOutput}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part.type === "tool_result") {
|
||||
if (part.toolName !== "call_sub_agent") return null;
|
||||
return (
|
||||
<ToolCallBlock
|
||||
key={i}
|
||||
@ -393,6 +399,9 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
args={part.toolArgs || {}}
|
||||
status={part.toolStatus || "ok"}
|
||||
result={part.content}
|
||||
childrenId={part.children_id}
|
||||
subAgentOutput={part.subAgentOutput}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
ModelSelectorLogo,
|
||||
} from "@/components/ai-elements/model-selector";
|
||||
import type { ModelSelectorLogoProps } from "@/components/ai-elements/model-selector";
|
||||
import { t } from "@/i18n/T";
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
"#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
|
||||
@ -105,21 +106,21 @@ export function ChatModelSelector({ selectedModel, onSelect, conversationId, onS
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="max-w-[140px] truncate font-medium">
|
||||
{selectedModel?.model_name || "Select Model"}
|
||||
{selectedModel?.model_name || t("chat.model_selector.search_placeholder").replace("Search ", "") || "Model"}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</ModelSelectorTrigger>
|
||||
<ModelSelectorContent>
|
||||
<ModelSelectorInput placeholder="Search models..." />
|
||||
<ModelSelectorInput placeholder={t("chat.model_selector.search_placeholder")} />
|
||||
<ModelSelectorList>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-6 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
{t("chat.model_selector.loading")}
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||
No models found
|
||||
{t("chat.model_selector.no_models")}
|
||||
</div>
|
||||
) : (
|
||||
<ModelSelectorGroup>
|
||||
|
||||
150
src/app/chat/ChatSlashCommandMenu.tsx
Normal file
150
src/app/chat/ChatSlashCommandMenu.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { FolderGit2, Sparkles } from "lucide-react";
|
||||
import type { SelectedChatContext } from "./chatSlashContext";
|
||||
|
||||
interface SlashCommandState {
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface ChatSlashCommandMenuProps {
|
||||
open: boolean;
|
||||
state: SlashCommandState | null;
|
||||
activeIndex: number;
|
||||
items: SelectedChatContext[];
|
||||
selectedKeys: Set<string>;
|
||||
onPickItem: (item: SelectedChatContext) => void;
|
||||
onHoverIndex: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ChatSlashCommandMenu({
|
||||
open,
|
||||
state,
|
||||
activeIndex,
|
||||
items,
|
||||
selectedKeys,
|
||||
onPickItem,
|
||||
onHoverIndex,
|
||||
}: ChatSlashCommandMenuProps) {
|
||||
if (!open || !state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-full z-20 mb-2 overflow-hidden rounded-2xl border shadow-2xl"
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
borderColor: "var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="border-b px-3 py-2 text-[11px] uppercase tracking-[0.16em]"
|
||||
style={{
|
||||
borderColor: "var(--border-subtle)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{state.query ? `Select Context: ${state.query}` : "Select Repo or Skill"}
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto p-2">
|
||||
{items.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, index) => {
|
||||
const selected = selectedKeys.has(`${item.kind}:${item.id}`);
|
||||
const isRepo = item.kind === "repo";
|
||||
const Icon = isRepo ? FolderGit2 : Sparkles;
|
||||
const tagLabel = isRepo ? "Repo" : "Skill";
|
||||
const metaText = isRepo
|
||||
? item.project_name
|
||||
? `${item.project_name}/${item.repo_name}`
|
||||
: item.repo_name
|
||||
: `${item.slug}${item.description ? ` - ${item.description}` : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.kind}:${item.id}`}
|
||||
type="button"
|
||||
onMouseEnter={() => onHoverIndex(index)}
|
||||
onClick={() => onPickItem(item)}
|
||||
className="flex w-full items-start justify-between gap-3 rounded-xl px-3 py-2 text-left transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
activeIndex === index
|
||||
? "var(--hover-bg)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className="mt-0.5 rounded-lg p-1.5"
|
||||
style={{
|
||||
backgroundColor: "var(--accent-muted)",
|
||||
color: "var(--accent)",
|
||||
}}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="truncate text-sm font-medium"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: "var(--surface-ground)",
|
||||
color: "var(--text-muted)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
{tagLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-0.5 truncate text-xs"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{metaText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: "var(--accent-muted)",
|
||||
color: "var(--accent)",
|
||||
}}
|
||||
>
|
||||
Added
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message="No repositories or skills match this query." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
className="px-3 py-6 text-center text-sm"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { SlashCommandState };
|
||||
136
src/app/chat/chatSlashContext.ts
Normal file
136
src/app/chat/chatSlashContext.ts
Normal file
@ -0,0 +1,136 @@
|
||||
export type ChatSlashCommandName = "repo" | "skill";
|
||||
|
||||
export interface SelectedRepoContext {
|
||||
kind: "repo";
|
||||
id: string;
|
||||
repo_name: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
project_name?: string | null;
|
||||
}
|
||||
|
||||
export interface SelectedSkillContext {
|
||||
kind: "skill";
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export type SelectedChatContext = SelectedRepoContext | SelectedSkillContext;
|
||||
|
||||
interface SlashContextMetadataPayload {
|
||||
slash_context?: {
|
||||
repos?: Array<{
|
||||
id: string;
|
||||
repo_name: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
project_name?: string | null;
|
||||
}>;
|
||||
skills?: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
export function buildSlashContextMetadata(
|
||||
contexts: SelectedChatContext[]
|
||||
): Record<string, unknown> {
|
||||
const repos = contexts
|
||||
.filter(
|
||||
(context): context is SelectedRepoContext => context.kind === "repo"
|
||||
)
|
||||
.map((repo) => ({
|
||||
id: repo.id,
|
||||
repo_name: repo.repo_name,
|
||||
label: repo.label,
|
||||
description: repo.description ?? null,
|
||||
project_name: repo.project_name ?? null,
|
||||
}));
|
||||
|
||||
const skills = contexts
|
||||
.filter(
|
||||
(context): context is SelectedSkillContext => context.kind === "skill"
|
||||
)
|
||||
.map((skill) => ({
|
||||
id: skill.id,
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
label: skill.label,
|
||||
description: skill.description ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
slash_context: {
|
||||
repos,
|
||||
skills,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSlashContextMetadata(
|
||||
metadata: unknown
|
||||
): SelectedChatContext[] {
|
||||
if (!isRecord(metadata)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const payload = metadata as SlashContextMetadataPayload;
|
||||
const slashContext = payload.slash_context;
|
||||
if (!slashContext || !isRecord(slashContext)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contexts: SelectedChatContext[] = [];
|
||||
|
||||
const repos = Array.isArray(slashContext.repos) ? slashContext.repos : [];
|
||||
for (const repo of repos) {
|
||||
if (!isRecord(repo)) continue;
|
||||
const id = asString(repo.id);
|
||||
const repoName = asString(repo.repo_name);
|
||||
if (!id || !repoName) continue;
|
||||
|
||||
contexts.push({
|
||||
kind: "repo",
|
||||
id,
|
||||
repo_name: repoName,
|
||||
label: asString(repo.label) ?? repoName,
|
||||
description: asString(repo.description) ?? undefined,
|
||||
project_name: asString(repo.project_name) ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const skills = Array.isArray(slashContext.skills) ? slashContext.skills : [];
|
||||
for (const skill of skills) {
|
||||
if (!isRecord(skill)) continue;
|
||||
const id = asString(skill.id);
|
||||
const slug = asString(skill.slug);
|
||||
const name = asString(skill.name);
|
||||
if (!id || !slug || !name) continue;
|
||||
|
||||
contexts.push({
|
||||
kind: "skill",
|
||||
id,
|
||||
slug,
|
||||
name,
|
||||
label: asString(skill.label) ?? name,
|
||||
description: asString(skill.description) ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user