feat: update workplan chat components (conversation, sidebar, bubble, utils)

This commit is contained in:
zhenyi 2026-05-30 15:08:18 +08:00
parent 600ed2de35
commit 3b63a43404
4 changed files with 86 additions and 8 deletions

View File

@ -22,11 +22,15 @@ import type {
} from "./chat/types";
import { EMPTY_STREAM } from "./chat/types";
import { ModelSelectorPopover } from "./chat/model-selector-popover";
import { RepoMentionPopover } from "./chat/repo-mention-popover";
import { MessageBubble } from "./chat/message-bubble";
import { StreamingView } from "./chat/streaming-view";
import { CodePreviewProvider } from "./chat/code-preview-context";
import { CodePreviewPanel } from "./chat/code-preview-panel";
import { MessageNavDots } from "./chat/message-nav-dots";
import { mentionAtCursor } from "@/components/ai-elements/mention-textarea-overlay";
import { extractMentions } from "@/lib/ir/parser";
import { MentionChip } from "@/lib/ir/mention-chip";
// ---- Helpers ----
@ -514,6 +518,28 @@ function ChatInner({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// ---- Mention-aware backspace ----
if (e.key === "Backspace") {
const ta = e.currentTarget;
const { selectionStart, selectionEnd } = ta;
if (selectionStart === selectionEnd && selectionStart > 0) {
const hit = mentionAtCursor(textInput.value, selectionStart);
if (hit) {
e.preventDefault();
const newText =
textInput.value.slice(0, hit.start) +
textInput.value.slice(hit.end);
textInput.setInput(newText);
// Restore cursor to the position where the mention was.
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = hit.start;
});
return;
}
}
}
// ---- Enter to submit ----
if (e.key === "Enter" && !e.shiftKey && !sending) {
e.preventDefault();
const text = textInput.value;
@ -546,7 +572,7 @@ function ChatInner({
<Sparkles className="size-3 text-primary/70" />
</div>
<h1 className="truncate text-sm font-semibold text-foreground">
{conversation?.title ?? "Loading..."}
{conversation?.title && conversation.title.trim().length > 2 ? conversation.title : "New conversation"}
</h1>
</div>
</header>
@ -626,6 +652,35 @@ function ChatInner({
{/* Composer */}
<footer className="shrink-0 border-t border-border/60 bg-card/70 backdrop-blur-md">
<div className="mx-auto max-w-3xl px-4 py-3">
{/* Repo mention popover — positioned above the input */}
<div className="relative">
<RepoMentionPopover
workspaceName={projectName}
textValue={textInput.value}
onInsertMention={(mentionText, replaceFrom, replaceTo) => {
const before = textInput.value.slice(0, replaceFrom);
const after = textInput.value.slice(replaceTo);
textInput.setInput(before + mentionText + after);
}}
/>
</div>
{/* Active mention chips — shows which repos are referenced */}
{(() => {
const mentions = extractMentions(textInput.value);
if (mentions.length === 0) return null;
return (
<div className="mb-1.5 flex flex-wrap gap-1.5">
{mentions.map((m, i) => (
<MentionChip
key={`${m.entityId}-${i}`}
entityType={m.entityType}
entityId={m.entityId}
entityLabel={m.entityLabel}
/>
))}
</div>
);
})()}
<PromptInput
globalDrop={false}
onSubmit={({ text }) => handleSubmit(text)}
@ -633,7 +688,7 @@ function ChatInner({
<PromptInputBody>
<PromptInputTextarea
className="min-h-[48px] max-h-[200px] resize-none rounded-xl border-border/60 bg-muted/40 text-sm shadow-inner transition-colors focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-primary/20"
placeholder="Type a message…"
placeholder="Type a message… (use @ to mention a repo)"
onKeyDown={handleKeyDown}
/>
</PromptInputBody>

View File

@ -152,7 +152,7 @@ export function WorkplanChatSidebar() {
<MessageSquare className="size-3.5 text-primary/70" />
</div>
<span className="text-xs font-semibold uppercase tracking-[0.06em] text-muted-foreground">
AI Chats
Chats
</span>
</div>
<motion.button

View File

@ -1,5 +1,5 @@
import { memo } from "react";
import { User, Sparkles } from "lucide-react";
import { User, Sparkles, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Reasoning,
@ -7,6 +7,7 @@ import {
ReasoningContent,
} from "@/components/ai-elements/reasoning";
import { MarkdownRenderer } from "@/components/MarkdownRenderer";
import { MentionRenderer } from "@/lib/ir/mention-renderer";
import { getProviderIcon } from "./provider-icon";
import { isVisibleToolCall } from "./tool-utils";
import { ToolCallList } from "./tool-call-list";
@ -28,6 +29,7 @@ export const MessageBubble = memo(function MessageBubble({
!isUser &&
msg.tool_calls &&
msg.tool_calls.some((tc) => isVisibleToolCall(tc.name));
const isError = !isUser && /^Error:|I encountered an error/i.test(msg.content);
return (
<div
@ -55,11 +57,30 @@ export const MessageBubble = memo(function MessageBubble({
"relative overflow-hidden text-sm leading-relaxed shadow-sm",
isUser
? "rounded-2xl rounded-tr-sm bg-primary text-primary-foreground px-4 py-2.5"
: "rounded-2xl rounded-tl-sm bg-muted/70 text-foreground border border-border/40 px-4 py-2.5",
: isError
? "rounded-2xl rounded-tl-sm bg-destructive/[0.06] text-foreground border border-destructive/20 px-4 py-2.5"
: "rounded-2xl rounded-tl-sm bg-muted/70 text-foreground border border-border/40 px-4 py-2.5",
)}
>
{isUser ? (
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
<div className="whitespace-pre-wrap break-words">
<MentionRenderer content={msg.content} />
</div>
) : isError ? (
<div className="flex items-start gap-2.5">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive/60" />
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-destructive/80">Something went wrong</p>
<div className="mt-1 text-destructive/60">
<MarkdownRenderer
content={msg.content.replace(/^(Error:\s*|I encountered an error while processing your request:\s*)/i, "")}
onOpenCodePanel={(payload) =>
openCodePreview({ ...payload, id: crypto.randomUUID() })
}
/>
</div>
</div>
</div>
) : (
<MarkdownRenderer
content={msg.content}

View File

@ -16,6 +16,8 @@ export function formatTime(iso: string | null | undefined): string {
}
export function truncateTitle(title: string): string {
if (title.length <= MAX_TITLE_LENGTH) return title;
return title.slice(0, MAX_TITLE_LENGTH) + "\u2026";
const trimmed = title.trim();
if (!trimmed || trimmed.length <= 2) return "New conversation";
if (trimmed.length <= MAX_TITLE_LENGTH) return trimmed;
return trimmed.slice(0, MAX_TITLE_LENGTH) + "\u2026";
}