feat(chat): add slash command menu and context

This commit is contained in:
ZhenYi 2026-05-17 16:36:56 +08:00
parent 44944efd9b
commit dfc89cbbaa
8 changed files with 702 additions and 54 deletions

View File

@ -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">

View File

@ -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>

View File

@ -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}
/>
);
}

View File

@ -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);
}}

View File

@ -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}
/>
);
}

View File

@ -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>

View 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 };

View 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;
}