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";
|
} from "./chat/types";
|
||||||
import { EMPTY_STREAM } from "./chat/types";
|
import { EMPTY_STREAM } from "./chat/types";
|
||||||
import { ModelSelectorPopover } from "./chat/model-selector-popover";
|
import { ModelSelectorPopover } from "./chat/model-selector-popover";
|
||||||
|
import { RepoMentionPopover } from "./chat/repo-mention-popover";
|
||||||
import { MessageBubble } from "./chat/message-bubble";
|
import { MessageBubble } from "./chat/message-bubble";
|
||||||
import { StreamingView } from "./chat/streaming-view";
|
import { StreamingView } from "./chat/streaming-view";
|
||||||
import { CodePreviewProvider } from "./chat/code-preview-context";
|
import { CodePreviewProvider } from "./chat/code-preview-context";
|
||||||
import { CodePreviewPanel } from "./chat/code-preview-panel";
|
import { CodePreviewPanel } from "./chat/code-preview-panel";
|
||||||
import { MessageNavDots } from "./chat/message-nav-dots";
|
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 ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
@ -514,6 +518,28 @@ function ChatInner({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(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) {
|
if (e.key === "Enter" && !e.shiftKey && !sending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = textInput.value;
|
const text = textInput.value;
|
||||||
@ -546,7 +572,7 @@ function ChatInner({
|
|||||||
<Sparkles className="size-3 text-primary/70" />
|
<Sparkles className="size-3 text-primary/70" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="truncate text-sm font-semibold text-foreground">
|
<h1 className="truncate text-sm font-semibold text-foreground">
|
||||||
{conversation?.title ?? "Loading..."}
|
{conversation?.title && conversation.title.trim().length > 2 ? conversation.title : "New conversation"}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -626,6 +652,35 @@ function ChatInner({
|
|||||||
{/* Composer */}
|
{/* Composer */}
|
||||||
<footer className="shrink-0 border-t border-border/60 bg-card/70 backdrop-blur-md">
|
<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">
|
<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
|
<PromptInput
|
||||||
globalDrop={false}
|
globalDrop={false}
|
||||||
onSubmit={({ text }) => handleSubmit(text)}
|
onSubmit={({ text }) => handleSubmit(text)}
|
||||||
@ -633,7 +688,7 @@ function ChatInner({
|
|||||||
<PromptInputBody>
|
<PromptInputBody>
|
||||||
<PromptInputTextarea
|
<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"
|
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}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
</PromptInputBody>
|
</PromptInputBody>
|
||||||
|
|||||||
@ -152,7 +152,7 @@ export function WorkplanChatSidebar() {
|
|||||||
<MessageSquare className="size-3.5 text-primary/70" />
|
<MessageSquare className="size-3.5 text-primary/70" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.06em] text-muted-foreground">
|
<span className="text-xs font-semibold uppercase tracking-[0.06em] text-muted-foreground">
|
||||||
AI Chats
|
Chats
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { User, Sparkles } from "lucide-react";
|
import { User, Sparkles, AlertTriangle } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Reasoning,
|
Reasoning,
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
} from "@/components/ai-elements/reasoning";
|
} from "@/components/ai-elements/reasoning";
|
||||||
import { MarkdownRenderer } from "@/components/MarkdownRenderer";
|
import { MarkdownRenderer } from "@/components/MarkdownRenderer";
|
||||||
|
import { MentionRenderer } from "@/lib/ir/mention-renderer";
|
||||||
import { getProviderIcon } from "./provider-icon";
|
import { getProviderIcon } from "./provider-icon";
|
||||||
import { isVisibleToolCall } from "./tool-utils";
|
import { isVisibleToolCall } from "./tool-utils";
|
||||||
import { ToolCallList } from "./tool-call-list";
|
import { ToolCallList } from "./tool-call-list";
|
||||||
@ -28,6 +29,7 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
!isUser &&
|
!isUser &&
|
||||||
msg.tool_calls &&
|
msg.tool_calls &&
|
||||||
msg.tool_calls.some((tc) => isVisibleToolCall(tc.name));
|
msg.tool_calls.some((tc) => isVisibleToolCall(tc.name));
|
||||||
|
const isError = !isUser && /^Error:|I encountered an error/i.test(msg.content);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -55,11 +57,30 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
"relative overflow-hidden text-sm leading-relaxed shadow-sm",
|
"relative overflow-hidden text-sm leading-relaxed shadow-sm",
|
||||||
isUser
|
isUser
|
||||||
? "rounded-2xl rounded-tr-sm bg-primary text-primary-foreground px-4 py-2.5"
|
? "rounded-2xl rounded-tr-sm bg-primary text-primary-foreground 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",
|
: "rounded-2xl rounded-tl-sm bg-muted/70 text-foreground border border-border/40 px-4 py-2.5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isUser ? (
|
{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
|
<MarkdownRenderer
|
||||||
content={msg.content}
|
content={msg.content}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ export function formatTime(iso: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function truncateTitle(title: string): string {
|
export function truncateTitle(title: string): string {
|
||||||
if (title.length <= MAX_TITLE_LENGTH) return title;
|
const trimmed = title.trim();
|
||||||
return title.slice(0, MAX_TITLE_LENGTH) + "\u2026";
|
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