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 { useConversationsQuery, useCreateConversationMutation, useDeleteConversationMutation } from "@/hooks/useAiChatQuery";
|
||||||
import { useChatPage } from "./ChatPageContext";
|
import { useChatPage } from "./ChatPageContext";
|
||||||
import type { ConversationResponse } from "@/client/model";
|
import type { ConversationResponse } from "@/client/model";
|
||||||
|
import { t } from "@/i18n/T";
|
||||||
|
|
||||||
interface ChatConversationListProps {
|
interface ChatConversationListProps {
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
@ -139,14 +140,14 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
|||||||
style={{ borderBottom: "1px solid var(--border-subtle)" }}
|
style={{ borderBottom: "1px solid var(--border-subtle)" }}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
|
<span className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||||
Chat History
|
{t("chat.conversations.chat_history")}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
title="Search (Ctrl+K)"
|
title={t("navigation.search_shortcut")}
|
||||||
>
|
>
|
||||||
<Search className="w-3.5 h-3.5" />
|
<Search className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -155,7 +156,7 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
|||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||||
style={{ color: "var(--text-secondary)" }}
|
style={{ color: "var(--text-secondary)" }}
|
||||||
title="New chat"
|
title={t("chat.header.new_chat")}
|
||||||
>
|
>
|
||||||
{createMutation.isPending ? (
|
{createMutation.isPending ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
@ -180,7 +181,7 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
|||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search conversations..."
|
placeholder={t("chat.conversations.search_placeholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="flex-1 text-sm bg-transparent outline-none min-w-0"
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
New Chat
|
{t("chat.conversations.new_chat")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -248,13 +249,13 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
|||||||
className="text-[13px] font-medium text-center"
|
className="text-[13px] font-medium text-center"
|
||||||
style={{ color: "var(--text-primary)" }}
|
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>
|
||||||
<p
|
<p
|
||||||
className="text-[12px] text-center leading-relaxed"
|
className="text-[12px] text-center leading-relaxed"
|
||||||
style={{ color: "var(--text-muted)" }}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -337,7 +338,7 @@ function ConversationItem({
|
|||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-[13px] truncate leading-snug">
|
<p className="text-[13px] truncate leading-snug">
|
||||||
{conversation.title || "Untitled Chat"}
|
{conversation.title || t("chat.conversations.untitled_chat")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {Loader2, MoreHorizontal, Pencil, PanelLeftOpen, PanelLeftClose, Share2} from "lucide-react";
|
import {Loader2, MoreHorizontal, Pencil, PanelLeftOpen, PanelLeftClose, Share2} from "lucide-react";
|
||||||
import {useConversationQuery} from "@/hooks/useAiChatQuery";
|
import {useConversationQuery} from "@/hooks/useAiChatQuery";
|
||||||
|
import {t} from "@/i18n/T";
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
@ -11,7 +12,7 @@ interface ChatHeaderProps {
|
|||||||
export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onToggleSidebar}: ChatHeaderProps) {
|
export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onToggleSidebar}: ChatHeaderProps) {
|
||||||
const {data: conversation} = useConversationQuery(conversationId || "");
|
const {data: conversation} = useConversationQuery(conversationId || "");
|
||||||
|
|
||||||
const title = conversation?.title || "New Chat";
|
const title = conversation?.title || t("chat.header.new_chat");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -26,7 +27,7 @@ export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onT
|
|||||||
onClick={onToggleSidebar}
|
onClick={onToggleSidebar}
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||||
style={{color: "var(--text-muted)"}}
|
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"/>}
|
{isSidebarCollapsed ? <PanelLeftOpen className="w-4 h-4"/> : <PanelLeftClose className="w-4 h-4"/>}
|
||||||
</button>
|
</button>
|
||||||
@ -34,7 +35,7 @@ export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onT
|
|||||||
className="text-sm font-medium truncate"
|
className="text-sm font-medium truncate"
|
||||||
style={{color: "var(--text-primary)"}}
|
style={{color: "var(--text-primary)"}}
|
||||||
>
|
>
|
||||||
{conversationId ? title : "Chat"}
|
{conversationId ? title : t("search.chat")}
|
||||||
</h2>
|
</h2>
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span
|
<span
|
||||||
@ -42,7 +43,7 @@ export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onT
|
|||||||
style={{color: "var(--accent)"}}
|
style={{color: "var(--accent)"}}
|
||||||
>
|
>
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin"/>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -53,21 +54,21 @@ export function ChatHeader({conversationId, isStreaming, isSidebarCollapsed, onT
|
|||||||
<button
|
<button
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||||
style={{color: "var(--text-muted)"}}
|
style={{color: "var(--text-muted)"}}
|
||||||
title="Share conversation"
|
title={t("chat.header.share")}
|
||||||
>
|
>
|
||||||
<Share2 className="w-[14px] h-[14px]"/>
|
<Share2 className="w-[14px] h-[14px]"/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||||
style={{color: "var(--text-muted)"}}
|
style={{color: "var(--text-muted)"}}
|
||||||
title="Rename"
|
title={t("chat.header.rename")}
|
||||||
>
|
>
|
||||||
<Pencil className="w-[14px] h-[14px]"/>
|
<Pencil className="w-[14px] h-[14px]"/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||||
style={{color: "var(--text-muted)"}}
|
style={{color: "var(--text-muted)"}}
|
||||||
title="More"
|
title={t("chat.header.more")}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="w-[14px] h-[14px]"/>
|
<MoreHorizontal className="w-[14px] h-[14px]"/>
|
||||||
</button>
|
</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 { memo, useState, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
||||||
@ -18,7 +27,9 @@ import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-e
|
|||||||
import type { MessageResponse } from "@/hooks/useAiChatQuery";
|
import type { MessageResponse } from "@/hooks/useAiChatQuery";
|
||||||
import { getModelIcon } from "@/lib/icons/modelIcons";
|
import { getModelIcon } from "@/lib/icons/modelIcons";
|
||||||
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
|
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useChatPage } from "./ChatPageContext";
|
import { useChatPage } from "./ChatPageContext";
|
||||||
|
import { parseSlashContextMetadata } from "./chatSlashContext";
|
||||||
|
|
||||||
interface ChatMessageBubbleProps {
|
interface ChatMessageBubbleProps {
|
||||||
message: MessageResponse;
|
message: MessageResponse;
|
||||||
@ -60,9 +71,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
|||||||
|
|
||||||
// Parse content into IrContentBlock[] (handles both old and future formats)
|
// Parse content into IrContentBlock[] (handles both old and future formats)
|
||||||
const blocks: IrContentBlock[] = useMemo(() =>
|
const blocks: IrContentBlock[] = useMemo(() =>
|
||||||
isUser
|
isUser ? [] : parseContentBlocks(message.content),
|
||||||
? [{ role: "user", nodes: [] }]
|
|
||||||
: parseContentBlocks(message.content),
|
|
||||||
[isUser, message.content]
|
[isUser, message.content]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -74,6 +83,10 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
|||||||
: "";
|
: "";
|
||||||
const plainText = isUser ? userText : extractAnswerText(blocks);
|
const plainText = isUser ? userText : extractAnswerText(blocks);
|
||||||
const hasThinking = blocks.some((b) => b.role === "thinking");
|
const hasThinking = blocks.some((b) => b.role === "thinking");
|
||||||
|
const selectedContexts = useMemo(
|
||||||
|
() => parseSlashContextMetadata(message.metadata),
|
||||||
|
[message.metadata]
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch versions when showing version switcher
|
// Fetch versions when showing version switcher
|
||||||
const versionsQuery = useMessageVersionsQuery(conversationId, message.id);
|
const versionsQuery = useMessageVersionsQuery(conversationId, message.id);
|
||||||
@ -194,6 +207,28 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
|||||||
|
|
||||||
{/* Interleaved blocks — thinking (collapsible) + answer (IrRenderer) */}
|
{/* Interleaved blocks — thinking (collapsible) + answer (IrRenderer) */}
|
||||||
<div className="text-sm" style={{ color: "var(--text-primary)" }}>
|
<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 ? (
|
{isUser && isEditing ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
@ -260,13 +295,14 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
|||||||
if (b.role === "tool_call") {
|
if (b.role === "tool_call") {
|
||||||
// Tool call visualization
|
// Tool call visualization
|
||||||
const toolCallNode = b.nodes.find((n) => n.type === "tool_call") as IrToolCallNode | undefined;
|
const toolCallNode = b.nodes.find((n) => n.type === "tool_call") as IrToolCallNode | undefined;
|
||||||
if (toolCallNode) {
|
if (toolCallNode && toolCallNode.tool === "call_sub_agent") {
|
||||||
return (
|
return (
|
||||||
<ToolCallBlock
|
<ToolCallBlock
|
||||||
key={i}
|
key={i}
|
||||||
toolName={toolCallNode.tool}
|
toolName={toolCallNode.tool}
|
||||||
args={toolCallNode.args}
|
args={toolCallNode.args}
|
||||||
status="ok"
|
status="ok"
|
||||||
|
conversationId={conversationId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -274,14 +310,17 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
|||||||
}
|
}
|
||||||
if (b.role === "tool_result") {
|
if (b.role === "tool_result") {
|
||||||
const toolResultNode = b.nodes.find((n) => n.type === "tool_result") as IrToolResultNode | undefined;
|
const toolResultNode = b.nodes.find((n) => n.type === "tool_result") as IrToolResultNode | undefined;
|
||||||
if (toolResultNode) {
|
if (toolResultNode && toolResultNode.tool === "call_sub_agent") {
|
||||||
return (
|
return (
|
||||||
<ToolCallBlock
|
<ToolCallBlock
|
||||||
key={i}
|
key={i}
|
||||||
toolName={toolResultNode.tool}
|
toolName={toolResultNode.tool}
|
||||||
args={{}}
|
args={{ role: toolResultNode.role, task: toolResultNode.task }}
|
||||||
status={toolResultNode.status}
|
status={toolResultNode.status}
|
||||||
result={toolResultNode.content}
|
result={toolResultNode.content}
|
||||||
|
childrenId={toolResultNode.children_id}
|
||||||
|
subAgentOutput={toolResultNode.content}
|
||||||
|
conversationId={conversationId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle, FolderGit2, Sparkles, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useCreateMessageMutation,
|
useCreateMessageMutation,
|
||||||
useCreateConversationMutation,
|
useCreateConversationMutation,
|
||||||
@ -17,6 +17,18 @@ import {
|
|||||||
import type { CreateMessageParams } from "@/client/model";
|
import type { CreateMessageParams } from "@/client/model";
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { ChatModelSelector } from "./ChatModelSelector";
|
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 {
|
interface ChatMessageInputProps {
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
@ -25,6 +37,61 @@ interface ChatMessageInputProps {
|
|||||||
onSelectConversation: (id: string) => void;
|
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({
|
export function ChatMessageInput({
|
||||||
conversationId,
|
conversationId,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@ -33,12 +100,134 @@ export function ChatMessageInput({
|
|||||||
}: ChatMessageInputProps) {
|
}: ChatMessageInputProps) {
|
||||||
const [showModelWarning, setShowModelWarning] = useState(false);
|
const [showModelWarning, setShowModelWarning] = useState(false);
|
||||||
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
|
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 createMessageMutation = useCreateMessageMutation();
|
||||||
const createConversationMutation = useCreateConversationMutation();
|
const createConversationMutation = useCreateConversationMutation();
|
||||||
const stopMessageMutation = useStopMessageMutation();
|
const stopMessageMutation = useStopMessageMutation();
|
||||||
const { scope, projectId, selectedModel, setSelectedModel } = useChatPage();
|
const { scope, projectId, projectName, selectedModel, setSelectedModel } =
|
||||||
|
useChatPage();
|
||||||
const runStream = useChatStreamRunner(setIsStreaming);
|
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) => {
|
const handleSubmit = async ({ text }: PromptInputMessage) => {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
@ -77,7 +266,10 @@ export function ChatMessageInput({
|
|||||||
model: selectedModel.model_name,
|
model: selectedModel.model_name,
|
||||||
parent_message_id: null,
|
parent_message_id: null,
|
||||||
is_fork_origin: false,
|
is_fork_origin: false,
|
||||||
metadata: {},
|
metadata:
|
||||||
|
selectedContexts.length > 0
|
||||||
|
? buildSlashContextMetadata(selectedContexts)
|
||||||
|
: {},
|
||||||
room_id: null,
|
room_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -88,6 +280,10 @@ export function ChatMessageInput({
|
|||||||
|
|
||||||
if (!messageResponse?.id) return;
|
if (!messageResponse?.id) return;
|
||||||
|
|
||||||
|
setDraft("");
|
||||||
|
setCaretPosition(0);
|
||||||
|
setSelectedContexts([]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setActiveMessageId(messageResponse.id);
|
setActiveMessageId(messageResponse.id);
|
||||||
setActiveStreamConversationId(activeConversationId);
|
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 (
|
return (
|
||||||
<div className="shrink-0 px-4 pb-4" style={{ backgroundColor: "var(--surface-ground)" }}>
|
<div
|
||||||
<div className="max-w-3xl mx-auto relative">
|
className="shrink-0 px-4 pb-4"
|
||||||
{/* Model warning */}
|
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 && (
|
{showModelWarning && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor: "var(--accent-muted)",
|
backgroundColor: "var(--accent-muted)",
|
||||||
color: "var(--accent)",
|
color: "var(--accent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AlertCircle className="w-3.5 h-3.5" />
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
Please select a model first
|
{t("chat.model_selector.please_select_model")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PromptInput onSubmit={handleSubmit}>
|
<PromptInput onSubmit={handleSubmit}>
|
||||||
<PromptInputBody>
|
<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>
|
</PromptInputBody>
|
||||||
|
|
||||||
<PromptInputFooter>
|
<PromptInputFooter>
|
||||||
<ChatModelSelector
|
<ChatModelSelector
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
@ -133,7 +441,10 @@ export function ChatMessageInput({
|
|||||||
status={isStreaming ? "streaming" : "ready"}
|
status={isStreaming ? "streaming" : "ready"}
|
||||||
onStop={() => {
|
onStop={() => {
|
||||||
if (activeStreamConversationId && activeMessageId) {
|
if (activeStreamConversationId && activeMessageId) {
|
||||||
stopMessageMutation.mutate({ conversationId: activeStreamConversationId, messageId: activeMessageId });
|
stopMessageMutation.mutate({
|
||||||
|
conversationId: activeStreamConversationId,
|
||||||
|
messageId: activeMessageId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -13,24 +13,25 @@ import { Shimmer } from "@/components/ai-elements/shimmer";
|
|||||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
|
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
|
||||||
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
|
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
|
||||||
import { useCodePreview } from "@/components/chat/CodePreviewContext";
|
import { useCodePreview } from "@/components/chat/CodePreviewContext";
|
||||||
|
import { t } from "@/i18n/T";
|
||||||
|
|
||||||
interface ChatMessageListProps {
|
interface ChatMessageListProps {
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
setIsStreaming: (value: boolean) => void;
|
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 OVERSCAN = 3;
|
||||||
const ESTIMATED_SIZE = 200;
|
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 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) {
|
export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageListProps) {
|
||||||
const { data, isLoading } = useMessagesQuery(conversationId || "");
|
const { data, isLoading } = useMessagesQuery(conversationId || "");
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@ -148,10 +149,10 @@ export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageL
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-[28px] font-semibold mb-2 tracking-tight" style={{ color: "var(--text-primary)" }}>
|
<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>
|
</h1>
|
||||||
<p className="text-[15px]" style={{ color: "var(--text-muted)", lineHeight: "1.6" }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 w-full max-w-md">
|
<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">
|
<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)" }} />
|
<ChevronDown className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -242,8 +243,8 @@ export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageL
|
|||||||
virtualizer.scrollToIndex(messageIndex, { align: "center", behavior: "smooth" });
|
virtualizer.scrollToIndex(messageIndex, { align: "center", behavior: "smooth" });
|
||||||
}}
|
}}
|
||||||
className="pointer-events-auto flex h-4 w-4 items-center justify-center rounded-full transition-all"
|
className="pointer-events-auto flex h-4 w-4 items-center justify-center rounded-full transition-all"
|
||||||
title={`Jump to your message ${anchorIndex + 1}`}
|
title={t("chat.message_list.jump_to_message", { index: anchorIndex + 1 })}
|
||||||
aria-label={`Jump to your message ${anchorIndex + 1}`}
|
aria-label={t("chat.message_list.jump_to_message", { index: anchorIndex + 1 })}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="block rounded-full transition-all"
|
className="block rounded-full transition-all"
|
||||||
@ -296,7 +297,7 @@ export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageL
|
|||||||
|
|
||||||
{hasStreamingBubble && (
|
{hasStreamingBubble && (
|
||||||
<div className="max-w-3xl mx-auto w-full">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 { selectedModel } = useChatPage();
|
||||||
const [displayParts, setDisplayParts] = useState<StreamPart[]>([]);
|
const [displayParts, setDisplayParts] = useState<StreamPart[]>([]);
|
||||||
const [displayDone, setDisplayDone] = useState(false);
|
const [displayDone, setDisplayDone] = useState(false);
|
||||||
@ -359,7 +360,7 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
|||||||
</span>
|
</span>
|
||||||
{!displayDone && (
|
{!displayDone && (
|
||||||
<span className="text-[11px] animate-pulse" style={{ color: "var(--text-muted)" }}>
|
<span className="text-[11px] animate-pulse" style={{ color: "var(--text-muted)" }}>
|
||||||
responding...
|
{t("chat.message_list.responding")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -376,16 +377,21 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (part.type === "tool_call") {
|
if (part.type === "tool_call") {
|
||||||
|
if (part.toolName !== "call_sub_agent") return null;
|
||||||
return (
|
return (
|
||||||
<ToolCallBlock
|
<ToolCallBlock
|
||||||
key={i}
|
key={i}
|
||||||
toolName={part.toolName || "unknown"}
|
toolName={part.toolName || "unknown"}
|
||||||
args={part.toolArgs || {}}
|
args={part.toolArgs || {}}
|
||||||
status={displayDone ? "ok" : "pending"}
|
status={displayDone ? "ok" : "pending"}
|
||||||
|
childrenId={part.children_id}
|
||||||
|
subAgentOutput={part.subAgentOutput}
|
||||||
|
conversationId={conversationId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (part.type === "tool_result") {
|
if (part.type === "tool_result") {
|
||||||
|
if (part.toolName !== "call_sub_agent") return null;
|
||||||
return (
|
return (
|
||||||
<ToolCallBlock
|
<ToolCallBlock
|
||||||
key={i}
|
key={i}
|
||||||
@ -393,6 +399,9 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
|||||||
args={part.toolArgs || {}}
|
args={part.toolArgs || {}}
|
||||||
status={part.toolStatus || "ok"}
|
status={part.toolStatus || "ok"}
|
||||||
result={part.content}
|
result={part.content}
|
||||||
|
childrenId={part.children_id}
|
||||||
|
subAgentOutput={part.subAgentOutput}
|
||||||
|
conversationId={conversationId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
ModelSelectorLogo,
|
ModelSelectorLogo,
|
||||||
} from "@/components/ai-elements/model-selector";
|
} from "@/components/ai-elements/model-selector";
|
||||||
import type { ModelSelectorLogoProps } from "@/components/ai-elements/model-selector";
|
import type { ModelSelectorLogoProps } from "@/components/ai-elements/model-selector";
|
||||||
|
import { t } from "@/i18n/T";
|
||||||
|
|
||||||
const AVATAR_COLORS = [
|
const AVATAR_COLORS = [
|
||||||
"#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
|
"#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" />
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="max-w-[140px] truncate font-medium">
|
<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>
|
</span>
|
||||||
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
|
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</ModelSelectorTrigger>
|
</ModelSelectorTrigger>
|
||||||
<ModelSelectorContent>
|
<ModelSelectorContent>
|
||||||
<ModelSelectorInput placeholder="Search models..." />
|
<ModelSelectorInput placeholder={t("chat.model_selector.search_placeholder")} />
|
||||||
<ModelSelectorList>
|
<ModelSelectorList>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-6 text-sm text-muted-foreground">
|
<div className="flex justify-center py-6 text-sm text-muted-foreground">
|
||||||
Loading...
|
{t("chat.model_selector.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : models.length === 0 ? (
|
) : models.length === 0 ? (
|
||||||
<div className="text-center py-6 text-sm text-muted-foreground">
|
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||||
No models found
|
{t("chat.model_selector.no_models")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ModelSelectorGroup>
|
<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