feat: update workplan chat components (conversation, sidebar, bubble, utils)
This commit is contained in:
parent
600ed2de35
commit
3b63a43404
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user