From 2e2f17189a96d0f8b207e3e32c9f864141f7919e Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Mon, 6 Jul 2026 12:19:31 +0800 Subject: [PATCH 1/2] fix(chat): stop recent-conversations header overflowing in English UI Switch the sidebar section header from flex-1 truncation to the existing grid-cols-[minmax(0,1fr)_auto] pattern already used for history rows, so the count badge and share button never get squeezed by long English labels. Also shorten the English label to "Conversations". Co-Authored-By: Claude Sonnet 5 --- .../web/src/components/chat/ChatHistorySidebar.tsx | 4 ++-- crates/agent-gateway/web/src/i18n/config.ts | 2 +- crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx | 4 ++-- crates/agent-gui/src/i18n/config.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx index f281dc71..fe1a7a32 100644 --- a/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx @@ -1798,14 +1798,14 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
- ) : null} - - ) : ( -

- {t("chat.startChatDesc")} -

- )} -
+ ); diff --git a/crates/agent-gateway/web/src/components/chat/ChatEmptyState.tsx b/crates/agent-gateway/web/src/components/chat/ChatEmptyState.tsx new file mode 100644 index 00000000..cea184d8 --- /dev/null +++ b/crates/agent-gateway/web/src/components/chat/ChatEmptyState.tsx @@ -0,0 +1,187 @@ +import { + type CSSProperties, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useState, +} from "react"; + +import { FolderTree, Lightbulb, Settings, Wrench } from "@/components/icons"; +import { useLocale } from "@/i18n/LocaleContext"; +import type { SectionId } from "@/pages/settings/types"; + +type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "night"; + +const GREETING_KEYS: Record = { + morning: "chat.greetingMorning", + noon: "chat.greetingNoon", + afternoon: "chat.greetingAfternoon", + evening: "chat.greetingEvening", + night: "chat.greetingNight", +}; + +function resolveGreetingPeriod(hour: number): GreetingPeriod { + if (hour >= 5 && hour < 12) return "morning"; + if (hour >= 12 && hour < 14) return "noon"; + if (hour >= 14 && hour < 18) return "afternoon"; + if (hour >= 18 && hour < 23) return "evening"; + return "night"; +} + +function useGreetingPeriod() { + const [period, setPeriod] = useState(() => + resolveGreetingPeriod(new Date().getHours()), + ); + + useEffect(() => { + const timer = window.setInterval(() => { + setPeriod(resolveGreetingPeriod(new Date().getHours())); + }, 60_000); + return () => window.clearInterval(timer); + }, []); + + return period; +} + +const SUGGESTION_CARDS = [ + { + key: "explore", + icon: FolderTree, + accent: "199 89% 48%", + chipClassName: + "bg-sky-500/10 text-sky-600 group-hover:bg-sky-500/20 dark:bg-sky-400/10 dark:text-sky-400 dark:group-hover:bg-sky-400/20", + titleKey: "chat.suggestExploreTitle", + hintKey: "chat.suggestExploreHint", + promptKey: "chat.suggestExplorePrompt", + }, + { + key: "fix", + icon: Wrench, + accent: "38 92% 50%", + chipClassName: + "bg-amber-500/10 text-amber-600 group-hover:bg-amber-500/20 dark:bg-amber-400/10 dark:text-amber-400 dark:group-hover:bg-amber-400/20", + titleKey: "chat.suggestFixTitle", + hintKey: "chat.suggestFixHint", + promptKey: "chat.suggestFixPrompt", + }, + { + key: "ideate", + icon: Lightbulb, + accent: "160 84% 39%", + chipClassName: + "bg-emerald-500/10 text-emerald-600 group-hover:bg-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-400 dark:group-hover:bg-emerald-400/20", + titleKey: "chat.suggestIdeateTitle", + hintKey: "chat.suggestIdeateHint", + promptKey: "chat.suggestIdeatePrompt", + }, +] as const; + +export type ChatEmptyStateProps = { + variant: "no-models" | "start-chat"; + onOpenSettings?: (section?: SectionId) => void; + onSuggestionSelect?: (text: string) => void; +}; + +export function ChatEmptyState({ + variant, + onOpenSettings, + onSuggestionSelect, +}: ChatEmptyStateProps) { + const { t } = useLocale(); + const period = useGreetingPeriod(); + + // Drives the accent spotlight that follows the cursor inside each card. + const handleCardPointerMove = useCallback((event: ReactPointerEvent) => { + const card = event.currentTarget; + const rect = card.getBoundingClientRect(); + card.style.setProperty("--spot-x", `${event.clientX - rect.left}px`); + card.style.setProperty("--spot-y", `${event.clientY - rect.top}px`); + }, []); + + return ( +
+
+ + + {variant === "no-models" ? ( + <> +
+ {t("chat.welcome")} +
+
+ {t("chat.noModelSelected")} +
+
+ {t("chat.configureModel")} +
+ {onOpenSettings ? ( + + ) : null} + + ) : ( + <> +
+ {t(GREETING_KEYS[period])} +
+
+
+ {onSuggestionSelect ? ( +
+ {SUGGESTION_CARDS.map((card, index) => ( + + ))} +
+ ) : null} + + )} +
+ ); +} diff --git a/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx b/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx index 712b104e..813a46c6 100644 --- a/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx +++ b/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx @@ -113,6 +113,8 @@ export interface MentionComposerHandle { insertGitFileMention: (file: MentionComposerGitFileMention) => void; clear: () => void; focus: () => void; + /** Clear the composer and type `text` in with a typewriter animation. */ + typeText: (text: string) => void; } export type MentionComposerLargePaste = { @@ -201,6 +203,8 @@ const CARET_ANCHOR_TEXT = "\u200B"; const CARET_SPACER_TEXT = "\u00A0"; const IME_ENTER_SUPPRESS_WINDOW_MS = 300; const IME_COMPOSITION_END_ENTER_TAIL_MS = 80; +// Must match the .composer-typewriter-char animation duration in index.css. +const TYPEWRITER_CHAR_FADE_MS = 220; const GITHUB_ICON_SVG = ''; @@ -1669,6 +1673,28 @@ export const MentionComposer = memo( applyEmptyState(editorTextIsEmpty(el)); }, [applyEmptyState]); + // ---- Typewriter (typeText) ---- + const typewriterRef = useRef<{ timer: number; finish: () => void } | null>(null); + + const cancelTypewriter = useCallback(() => { + const active = typewriterRef.current; + if (!active) return; + typewriterRef.current = null; + window.clearTimeout(active.timer); + }, []); + + // Any user keystroke or paste completes the animation instantly so the + // user's input always lands after the full suggestion text. + const finishTypewriter = useCallback(() => { + const active = typewriterRef.current; + if (!active) return; + typewriterRef.current = null; + window.clearTimeout(active.timer); + active.finish(); + }, []); + + useEffect(() => cancelTypewriter, [cancelTypewriter]); + const buildDraft = useCallback((): MentionComposerDraft => { const el = editorRef.current; if (!el) { @@ -1817,6 +1843,7 @@ export const MentionComposer = memo( setText: (text: string) => { const el = editorRef.current; if (!el) return; + cancelTypewriter(); el.innerHTML = ""; largePastesRef.current.clear(); closeCommitTooltip(); @@ -1831,6 +1858,7 @@ export const MentionComposer = memo( setDraft: (draft: MentionComposerDraft) => { const el = editorRef.current; if (!el) return; + cancelTypewriter(); el.innerHTML = ""; largePastesRef.current.clear(); closeCommitTooltip(); @@ -1872,6 +1900,7 @@ export const MentionComposer = memo( insertFileMention: (path: string, kind: "file" | "dir") => { const el = editorRef.current; if (!el) return; + finishTypewriter(); el.focus(); const chip = createFileMentionChip(path, kind); if (!chip) return; @@ -1882,6 +1911,7 @@ export const MentionComposer = memo( insertCommitMention: (commit: MentionComposerCommitMention) => { const el = editorRef.current; if (!el) return; + finishTypewriter(); el.focus(); insertNodeAtCursor(el, createCommitMentionChip(commit), { ensureSpaceAfterNode: true }); closeMentionSession(); @@ -1890,6 +1920,7 @@ export const MentionComposer = memo( insertGitFileMention: (file: MentionComposerGitFileMention) => { const el = editorRef.current; if (!el) return; + finishTypewriter(); el.focus(); insertNodeAtCursor(el, createGitFileMentionChip(file), { ensureSpaceAfterNode: true }); closeMentionSession(); @@ -1898,6 +1929,7 @@ export const MentionComposer = memo( clear: () => { const el = editorRef.current; if (!el) return; + cancelTypewriter(); el.innerHTML = ""; largePastesRef.current.clear(); closeCommitTooltip(); @@ -1905,8 +1937,91 @@ export const MentionComposer = memo( refreshEmptyState(); }, focus: () => editorRef.current?.focus(), + typeText: (text: string) => { + const el = editorRef.current; + if (!el) return; + cancelTypewriter(); + el.innerHTML = ""; + largePastesRef.current.clear(); + closeCommitTooltip(); + closeMentionSession(); + el.focus({ preventScroll: true }); + + const chars = Array.from(text); + const textNode = document.createTextNode(""); + el.appendChild(textNode); + const placeCaretAtEnd = () => { + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + }; + if (chars.length === 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + textNode.data = chars.join(""); + placeCaretAtEnd(); + refreshEmptyState(); + return; + } + + // Freshly typed characters live in short-lived fade-in spans, then + // fold into the committed text node once their fade completes, so + // the editor always ends up holding one plain text node. + const ghosts: HTMLSpanElement[] = []; + const foldOldestGhost = () => { + const ghost = ghosts.shift(); + if (!ghost) return; + textNode.data += ghost.textContent ?? ""; + ghost.remove(); + }; + const finish = () => { + for (const ghost of ghosts) ghost.remove(); + ghosts.length = 0; + textNode.data = chars.join(""); + placeCaretAtEnd(); + refreshEmptyState(); + }; + + // Adaptive pace: long prompts speed up so the whole line lands in ~1s. + const tickMs = Math.max(12, Math.min(28, Math.round(900 / chars.length))); + const maxGhosts = Math.max(1, Math.ceil(TYPEWRITER_CHAR_FADE_MS / tickMs)); + let index = 0; + const tick = () => { + if (index < chars.length) { + const ghost = document.createElement("span"); + ghost.className = "composer-typewriter-char"; + ghost.textContent = chars[index] ?? ""; + el.appendChild(ghost); + ghosts.push(ghost); + index += 1; + while (ghosts.length > maxGhosts) foldOldestGhost(); + placeCaretAtEnd(); + refreshEmptyState(); + typewriterRef.current = { timer: window.setTimeout(tick, tickMs), finish }; + return; + } + if (ghosts.length > 0) { + foldOldestGhost(); + placeCaretAtEnd(); + typewriterRef.current = { timer: window.setTimeout(tick, tickMs), finish }; + return; + } + typewriterRef.current = null; + }; + refreshEmptyState(); + typewriterRef.current = { timer: window.setTimeout(tick, tickMs), finish }; + }, }), - [buildDraft, closeCommitTooltip, closeMentionSession, insertLargePaste, refreshEmptyState], + [ + buildDraft, + cancelTypewriter, + closeCommitTooltip, + closeMentionSession, + finishTypewriter, + insertLargePaste, + refreshEmptyState, + ], ); // ---- Select suggestion ---- @@ -2049,6 +2164,7 @@ export const MentionComposer = memo( e.preventDefault(); return; } + finishTypewriter(); const isEnter = isEnterKeyboardEvent(e); const isActiveCompositionKey = isComposingRef.current || isActiveImeKeyboardEvent(e); const hasLegacyImeSignal = hasLegacyImeKeyboardSignal(e); @@ -2166,6 +2282,7 @@ export const MentionComposer = memo( selectSuggestion, disabled, closeMentionSession, + finishTypewriter, onSend, refreshEmptyState, refreshMention, @@ -2181,6 +2298,7 @@ export const MentionComposer = memo( e.preventDefault(); return; } + finishTypewriter(); const clipboardFiles = extractClipboardFiles(e.clipboardData); if (clipboardFiles.length > 0) { e.preventDefault(); @@ -2197,7 +2315,14 @@ export const MentionComposer = memo( refreshEmptyState(); refreshMention(); }, - [disabled, insertLargePaste, onPasteFiles, refreshEmptyState, refreshMention], + [ + disabled, + finishTypewriter, + insertLargePaste, + onPasteFiles, + refreshEmptyState, + refreshMention, + ], ); const handleCompositionStart = useCallback(() => { diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 84a2e89f..a13e52e3 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -76,8 +76,21 @@ export const translations: Record> = { "chat.emptyChatHistory": "暂无历史对话", "chat.clickNewConversation": "点击上方「新对话」开始聊天", "chat.welcome": "欢迎使用 LiveAgent", - "chat.startChat": "开始对话", - "chat.startChatDesc": "在下方输入消息,开始与 AI 助手交流", + "chat.greetingMorning": "早上好", + "chat.greetingNoon": "中午好", + "chat.greetingAfternoon": "下午好", + "chat.greetingEvening": "晚上好", + "chat.greetingNight": "夜深了", + "chat.greetingSubtitle": "今天想从哪里开始?", + "chat.suggestExploreTitle": "解读项目", + "chat.suggestExploreHint": "梳理架构与模块职责", + "chat.suggestExplorePrompt": "帮我梳理这个项目的整体架构,并总结各个核心模块的职责。", + "chat.suggestFixTitle": "修复问题", + "chat.suggestFixHint": "描述问题,一起修复", + "chat.suggestFixPrompt": "帮我排查一个问题:", + "chat.suggestIdeateTitle": "头脑风暴", + "chat.suggestIdeateHint": "从想法到落地方案", + "chat.suggestIdeatePrompt": "我有一个新功能的想法,帮我一起设计实现方案:", "chat.noModelSelected": "你还没有配置模型。", "chat.configureModel": "点击下方按钮,配置供应商和模型。", "chat.goToSettings": "配置供应商", @@ -1565,8 +1578,22 @@ export const translations: Record> = { "chat.emptyChatHistory": "No conversation history", "chat.clickNewConversation": "Click 'New Conversation' above to start chatting", "chat.welcome": "Welcome to LiveAgent", - "chat.startChat": "Start Chatting", - "chat.startChatDesc": "Type a message below to start chatting with the AI assistant", + "chat.greetingMorning": "Good morning", + "chat.greetingNoon": "Good afternoon", + "chat.greetingAfternoon": "Good afternoon", + "chat.greetingEvening": "Good evening", + "chat.greetingNight": "Up late?", + "chat.greetingSubtitle": "Where should we start today?", + "chat.suggestExploreTitle": "Explore the project", + "chat.suggestExploreHint": "Architecture and modules", + "chat.suggestExplorePrompt": + "Walk me through this project's architecture and summarize what each core module does.", + "chat.suggestFixTitle": "Fix an issue", + "chat.suggestFixHint": "Find and fix a bug", + "chat.suggestFixPrompt": "Help me debug an issue: ", + "chat.suggestIdeateTitle": "Brainstorm ideas", + "chat.suggestIdeateHint": "From idea to plan", + "chat.suggestIdeatePrompt": "I have an idea for a new feature — help me design and plan it: ", "chat.noModelSelected": "You haven't configured a model yet.", "chat.configureModel": "Click the button below to configure a provider and model.", "chat.goToSettings": "Configure Provider", diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index 8ee3cbdb..fd0693c7 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -877,9 +877,66 @@ } } + /* Suggestion cards: `backwards` fill so hover transforms win after entry */ + .hero-card-entrance { + animation: heroFadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) var(--hero-delay, 0.3s) backwards; + } + + /* Suggestion cards: per-card accent (--card-accent) drives a cursor-tracking + spotlight (--spot-x/--spot-y set from JS), border tint, and glow shadow. */ + .hero-suggest-card { + position: relative; + overflow: hidden; + border: 1px solid hsl(var(--border) / 0.6); + background: hsl(var(--card) / 0.5); + transition: + transform 0.2s ease, + border-color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.25s ease; + } + + .hero-suggest-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient( + 140px circle at var(--spot-x, 50%) var(--spot-y, 50%), + hsl(var(--card-accent) / 0.14), + transparent 65% + ); + opacity: 0; + transition: opacity 0.25s ease; + pointer-events: none; + } + + .hero-suggest-card:hover { + transform: translateY(-2px); + border-color: hsl(var(--card-accent) / 0.45); + background: hsl(var(--card) / 0.9); + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.04), + 0 12px 32px -8px hsl(var(--card-accent) / 0.28); + } + + .hero-suggest-card:hover::before { + opacity: 1; + } + + .hero-suggest-card:active { + transform: translateY(0) scale(0.99); + } + + .dark .hero-suggest-card:hover { + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.3), + 0 12px 36px -8px hsl(var(--card-accent) / 0.35); + } + /* Icon float animation */ .hero-icon-float { - animation: heroFloat 4s ease-in-out infinite; + animation: heroFloat 5.5s ease-in-out infinite; } @keyframes heroFloat { @@ -892,6 +949,65 @@ } } + /* Aurora halo behind the hero logo: slow spin + breathing opacity */ + .hero-aura { + position: absolute; + inset: -18px; + border-radius: 9999px; + background: conic-gradient( + from 40deg, + hsl(213 94% 62% / 0.2), + hsl(268 83% 66% / 0.14), + hsl(174 68% 50% / 0.12), + hsl(31 92% 62% / 0.1), + hsl(213 94% 62% / 0.2) + ); + filter: blur(22px); + animation: + heroAuraSpin 18s linear infinite, + heroAuraBreathe 7s ease-in-out infinite; + pointer-events: none; + } + + .dark .hero-aura { + background: conic-gradient( + from 40deg, + hsl(213 94% 62% / 0.3), + hsl(268 83% 66% / 0.22), + hsl(174 68% 50% / 0.18), + hsl(31 92% 62% / 0.16), + hsl(213 94% 62% / 0.3) + ); + } + + @keyframes heroAuraSpin { + to { + transform: rotate(360deg); + } + } + + @keyframes heroAuraBreathe { + 0%, + 100% { + opacity: 0.65; + } + 50% { + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + .hero-icon-float, + .hero-aura, + .hero-entrance, + .hero-entrance-delay-1, + .hero-entrance-delay-2, + .hero-entrance-delay-3, + .hero-card-entrance { + animation: none; + } + } + /* Mention composer placeholder */ .mention-composer.is-empty::before { content: attr(data-placeholder); @@ -900,6 +1016,24 @@ position: absolute; } + /* Composer typewriter: freshly typed characters fade in softly. + Duration must match TYPEWRITER_CHAR_FADE_MS in MentionComposer.tsx. */ + .composer-typewriter-char { + opacity: 0; + animation: composerTypeCharIn 0.22s ease-out forwards; + } + + @keyframes composerTypeCharIn { + from { + opacity: 0; + filter: blur(2px); + } + to { + opacity: 1; + filter: blur(0); + } + } + /* Mention popup animations */ @keyframes mentionPopupIn { from { diff --git a/crates/agent-gateway/web/src/styles.css b/crates/agent-gateway/web/src/styles.css index bba2d33a..4ae73a47 100644 --- a/crates/agent-gateway/web/src/styles.css +++ b/crates/agent-gateway/web/src/styles.css @@ -1105,14 +1105,6 @@ html[data-liveagent-webui="gateway"] [role="textbox"]:focus-visible { padding: 24px 0 0; } -.gateway-empty-state h2 { - margin: 0; -} - -.gateway-empty-state p { - margin-bottom: 0; -} - .gateway-footer-strip { display: flex; align-items: center; @@ -3022,11 +3014,6 @@ html[data-liveagent-webui="gateway"] padding-top: 8px; } - .gateway-empty-state h2 { - font-size: 22px; - line-height: 1.2; - } - .gateway-composer-layer { padding-bottom: var(--gateway-chat-composer-bottom, 12px); } diff --git a/crates/agent-gui/src/components/chat/MentionComposer.tsx b/crates/agent-gui/src/components/chat/MentionComposer.tsx index 980088d9..01efc1aa 100644 --- a/crates/agent-gui/src/components/chat/MentionComposer.tsx +++ b/crates/agent-gui/src/components/chat/MentionComposer.tsx @@ -121,6 +121,8 @@ export interface MentionComposerHandle { insertGitFileMention: (file: MentionComposerGitFileMention) => void; clear: () => void; focus: () => void; + /** Clear the composer and type `text` in with a typewriter animation. */ + typeText: (text: string) => void; } export type MentionComposerLargePaste = { @@ -212,6 +214,8 @@ const CARET_ANCHOR_TEXT = "\u200B"; const CARET_SPACER_TEXT = "\u00A0"; const IME_ENTER_SUPPRESS_WINDOW_MS = 300; const IME_COMPOSITION_END_ENTER_TAIL_MS = 80; +// Must match the .composer-typewriter-char animation duration in index.css. +const TYPEWRITER_CHAR_FADE_MS = 220; const GITHUB_ICON_SVG = ''; @@ -1980,6 +1984,28 @@ export const MentionComposer = memo( applyEmptyState(editorTextIsEmpty(el)); }, [applyEmptyState]); + // ---- Typewriter (typeText) ---- + const typewriterRef = useRef<{ timer: number; finish: () => void } | null>(null); + + const cancelTypewriter = useCallback(() => { + const active = typewriterRef.current; + if (!active) return; + typewriterRef.current = null; + window.clearTimeout(active.timer); + }, []); + + // Any user keystroke or paste completes the animation instantly so the + // user's input always lands after the full suggestion text. + const finishTypewriter = useCallback(() => { + const active = typewriterRef.current; + if (!active) return; + typewriterRef.current = null; + window.clearTimeout(active.timer); + active.finish(); + }, []); + + useEffect(() => cancelTypewriter, [cancelTypewriter]); + const buildDraft = useCallback((): MentionComposerDraft => { const el = editorRef.current; if (!el) { @@ -2128,6 +2154,7 @@ export const MentionComposer = memo( setText: (text: string) => { const el = editorRef.current; if (!el) return; + cancelTypewriter(); el.innerHTML = ""; largePastesRef.current.clear(); closeCommitTooltip(); @@ -2143,6 +2170,7 @@ export const MentionComposer = memo( setDraft: (draft: MentionComposerDraft) => { const el = editorRef.current; if (!el) return; + cancelTypewriter(); el.innerHTML = ""; largePastesRef.current.clear(); closeCommitTooltip(); @@ -2177,6 +2205,7 @@ export const MentionComposer = memo( insertFileMention: (path: string, kind: "file" | "dir") => { const el = editorRef.current; if (!el) return; + finishTypewriter(); el.focus(); const chip = createFileMentionChip(path, kind); if (!chip) return; @@ -2187,6 +2216,7 @@ export const MentionComposer = memo( insertCommitMention: (commit: MentionComposerCommitMention) => { const el = editorRef.current; if (!el) return; + finishTypewriter(); el.focus(); insertNodeAtCursor(el, createCommitMentionChip(commit), { ensureSpaceAfterNode: true }); closeMentionSession(); @@ -2195,6 +2225,7 @@ export const MentionComposer = memo( insertGitFileMention: (file: MentionComposerGitFileMention) => { const el = editorRef.current; if (!el) return; + finishTypewriter(); el.focus(); insertNodeAtCursor(el, createGitFileMentionChip(file), { ensureSpaceAfterNode: true }); closeMentionSession(); @@ -2203,6 +2234,7 @@ export const MentionComposer = memo( clear: () => { const el = editorRef.current; if (!el) return; + cancelTypewriter(); el.innerHTML = ""; largePastesRef.current.clear(); closeCommitTooltip(); @@ -2211,12 +2243,90 @@ export const MentionComposer = memo( refreshEmptyState(); }, focus: () => editorRef.current?.focus(), + typeText: (text: string) => { + const el = editorRef.current; + if (!el) return; + cancelTypewriter(); + el.innerHTML = ""; + largePastesRef.current.clear(); + closeCommitTooltip(); + closeComposerContextMenu(); + closeMentionSession(); + el.focus({ preventScroll: true }); + + const chars = Array.from(text); + const textNode = document.createTextNode(""); + el.appendChild(textNode); + const placeCaretAtEnd = () => { + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + }; + if (chars.length === 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + textNode.data = chars.join(""); + placeCaretAtEnd(); + refreshEmptyState(); + return; + } + + // Freshly typed characters live in short-lived fade-in spans, then + // fold into the committed text node once their fade completes, so + // the editor always ends up holding one plain text node. + const ghosts: HTMLSpanElement[] = []; + const foldOldestGhost = () => { + const ghost = ghosts.shift(); + if (!ghost) return; + textNode.data += ghost.textContent ?? ""; + ghost.remove(); + }; + const finish = () => { + for (const ghost of ghosts) ghost.remove(); + ghosts.length = 0; + textNode.data = chars.join(""); + placeCaretAtEnd(); + refreshEmptyState(); + }; + + // Adaptive pace: long prompts speed up so the whole line lands in ~1s. + const tickMs = Math.max(12, Math.min(28, Math.round(900 / chars.length))); + const maxGhosts = Math.max(1, Math.ceil(TYPEWRITER_CHAR_FADE_MS / tickMs)); + let index = 0; + const tick = () => { + if (index < chars.length) { + const ghost = document.createElement("span"); + ghost.className = "composer-typewriter-char"; + ghost.textContent = chars[index] ?? ""; + el.appendChild(ghost); + ghosts.push(ghost); + index += 1; + while (ghosts.length > maxGhosts) foldOldestGhost(); + placeCaretAtEnd(); + refreshEmptyState(); + typewriterRef.current = { timer: window.setTimeout(tick, tickMs), finish }; + return; + } + if (ghosts.length > 0) { + foldOldestGhost(); + placeCaretAtEnd(); + typewriterRef.current = { timer: window.setTimeout(tick, tickMs), finish }; + return; + } + typewriterRef.current = null; + }; + refreshEmptyState(); + typewriterRef.current = { timer: window.setTimeout(tick, tickMs), finish }; + }, }), [ buildDraft, + cancelTypewriter, closeCommitTooltip, closeComposerContextMenu, closeMentionSession, + finishTypewriter, insertLargePaste, refreshEmptyState, ], @@ -2529,6 +2639,7 @@ export const MentionComposer = memo( e.preventDefault(); return; } + finishTypewriter(); const isEnter = isEnterKeyboardEvent(e); const isActiveCompositionKey = isComposingRef.current || isActiveImeKeyboardEvent(e); const hasLegacyImeSignal = hasLegacyImeKeyboardSignal(e); @@ -2644,6 +2755,7 @@ export const MentionComposer = memo( selectSuggestion, disabled, closeMentionSession, + finishTypewriter, onSend, refreshEmptyState, refreshMention, @@ -2656,6 +2768,7 @@ export const MentionComposer = memo( e.preventDefault(); return; } + finishTypewriter(); const clipboardFiles = extractClipboardFiles(e.clipboardData); if (clipboardFiles.length > 0) { e.preventDefault(); @@ -2672,7 +2785,14 @@ export const MentionComposer = memo( refreshEmptyState(); refreshMention(); }, - [disabled, insertLargePaste, onPasteFiles, refreshEmptyState, refreshMention], + [ + disabled, + finishTypewriter, + insertLargePaste, + onPasteFiles, + refreshEmptyState, + refreshMention, + ], ); const handleCompositionStart = useCallback(() => { diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 111cf05b..fa30ca13 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -94,8 +94,21 @@ export const translations: Record> = { "chat.emptyChatHistory": "暂无历史对话", "chat.clickNewConversation": "点击上方「新对话」开始聊天", "chat.welcome": "欢迎使用 LiveAgent", - "chat.startChat": "开始对话", - "chat.startChatDesc": "在下方输入消息,开始与 AI 助手交流", + "chat.greetingMorning": "早上好", + "chat.greetingNoon": "中午好", + "chat.greetingAfternoon": "下午好", + "chat.greetingEvening": "晚上好", + "chat.greetingNight": "夜深了", + "chat.greetingSubtitle": "今天想从哪里开始?", + "chat.suggestExploreTitle": "解读项目", + "chat.suggestExploreHint": "梳理架构与模块职责", + "chat.suggestExplorePrompt": "帮我梳理这个项目的整体架构,并总结各个核心模块的职责。", + "chat.suggestFixTitle": "修复问题", + "chat.suggestFixHint": "描述问题,一起修复", + "chat.suggestFixPrompt": "帮我排查一个问题:", + "chat.suggestIdeateTitle": "头脑风暴", + "chat.suggestIdeateHint": "从想法到落地方案", + "chat.suggestIdeatePrompt": "我有一个新功能的想法,帮我一起设计实现方案:", "chat.noModelSelected": "你还没有配置模型。", "chat.configureModel": "点击下方按钮,配置供应商和模型。", "chat.goToSettings": "配置供应商", @@ -1640,8 +1653,22 @@ export const translations: Record> = { "chat.emptyChatHistory": "No conversation history", "chat.clickNewConversation": "Click 'New Conversation' above to start chatting", "chat.welcome": "Welcome to LiveAgent", - "chat.startChat": "Start Chatting", - "chat.startChatDesc": "Type a message below to start chatting with the AI assistant", + "chat.greetingMorning": "Good morning", + "chat.greetingNoon": "Good afternoon", + "chat.greetingAfternoon": "Good afternoon", + "chat.greetingEvening": "Good evening", + "chat.greetingNight": "Up late?", + "chat.greetingSubtitle": "Where should we start today?", + "chat.suggestExploreTitle": "Explore the project", + "chat.suggestExploreHint": "Architecture and modules", + "chat.suggestExplorePrompt": + "Walk me through this project's architecture and summarize what each core module does.", + "chat.suggestFixTitle": "Fix an issue", + "chat.suggestFixHint": "Find and fix a bug", + "chat.suggestFixPrompt": "Help me debug an issue: ", + "chat.suggestIdeateTitle": "Brainstorm ideas", + "chat.suggestIdeateHint": "From idea to plan", + "chat.suggestIdeatePrompt": "I have an idea for a new feature — help me design and plan it: ", "chat.noModelSelected": "You haven't configured a model yet.", "chat.configureModel": "Click the button below to configure a provider and model.", "chat.goToSettings": "Configure Provider", diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css index eeeedc76..d9b900a2 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -1078,9 +1078,66 @@ } } + /* Suggestion cards: `backwards` fill so hover transforms win after entry */ + .hero-card-entrance { + animation: heroFadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) var(--hero-delay, 0.3s) backwards; + } + + /* Suggestion cards: per-card accent (--card-accent) drives a cursor-tracking + spotlight (--spot-x/--spot-y set from JS), border tint, and glow shadow. */ + .hero-suggest-card { + position: relative; + overflow: hidden; + border: 1px solid hsl(var(--border) / 0.6); + background: hsl(var(--card) / 0.5); + transition: + transform 0.2s ease, + border-color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.25s ease; + } + + .hero-suggest-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient( + 140px circle at var(--spot-x, 50%) var(--spot-y, 50%), + hsl(var(--card-accent) / 0.14), + transparent 65% + ); + opacity: 0; + transition: opacity 0.25s ease; + pointer-events: none; + } + + .hero-suggest-card:hover { + transform: translateY(-2px); + border-color: hsl(var(--card-accent) / 0.45); + background: hsl(var(--card) / 0.9); + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.04), + 0 12px 32px -8px hsl(var(--card-accent) / 0.28); + } + + .hero-suggest-card:hover::before { + opacity: 1; + } + + .hero-suggest-card:active { + transform: translateY(0) scale(0.99); + } + + .dark .hero-suggest-card:hover { + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.3), + 0 12px 36px -8px hsl(var(--card-accent) / 0.35); + } + /* Icon float animation */ .hero-icon-float { - animation: heroFloat 4s ease-in-out infinite; + animation: heroFloat 5.5s ease-in-out infinite; } @keyframes heroFloat { @@ -1093,6 +1150,65 @@ } } + /* Aurora halo behind the hero logo: slow spin + breathing opacity */ + .hero-aura { + position: absolute; + inset: -18px; + border-radius: 9999px; + background: conic-gradient( + from 40deg, + hsl(213 94% 62% / 0.2), + hsl(268 83% 66% / 0.14), + hsl(174 68% 50% / 0.12), + hsl(31 92% 62% / 0.1), + hsl(213 94% 62% / 0.2) + ); + filter: blur(22px); + animation: + heroAuraSpin 18s linear infinite, + heroAuraBreathe 7s ease-in-out infinite; + pointer-events: none; + } + + .dark .hero-aura { + background: conic-gradient( + from 40deg, + hsl(213 94% 62% / 0.3), + hsl(268 83% 66% / 0.22), + hsl(174 68% 50% / 0.18), + hsl(31 92% 62% / 0.16), + hsl(213 94% 62% / 0.3) + ); + } + + @keyframes heroAuraSpin { + to { + transform: rotate(360deg); + } + } + + @keyframes heroAuraBreathe { + 0%, + 100% { + opacity: 0.65; + } + 50% { + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + .hero-icon-float, + .hero-aura, + .hero-entrance, + .hero-entrance-delay-1, + .hero-entrance-delay-2, + .hero-entrance-delay-3, + .hero-card-entrance { + animation: none; + } + } + /* Mention composer placeholder */ .mention-composer.is-empty::before { content: attr(data-placeholder); @@ -1101,6 +1217,24 @@ position: absolute; } + /* Composer typewriter: freshly typed characters fade in softly. + Duration must match TYPEWRITER_CHAR_FADE_MS in MentionComposer.tsx. */ + .composer-typewriter-char { + opacity: 0; + animation: composerTypeCharIn 0.22s ease-out forwards; + } + + @keyframes composerTypeCharIn { + from { + opacity: 0; + filter: blur(2px); + } + to { + opacity: 1; + filter: blur(0); + } + } + /* Mention popup animations */ @keyframes mentionPopupIn { from { diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 68dbcbad..43432e77 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -1471,6 +1471,9 @@ export function ChatPage(props: ChatPageProps) { composerRef.current?.insertGitFileMention(file); composerRef.current?.focus(); }, []); + const handleEmptyStateSuggestion = useCallback((text: string) => { + composerRef.current?.typeText(text); + }, []); const hideWorkspaceSshTerminalOverlay = useCallback(() => { setWorkspaceSshTerminalOpen(false); }, []); @@ -5489,6 +5492,7 @@ export function ChatPage(props: ChatPageProps) { setCopiedMessageKey={setCopiedMessageKey} onResendFromEdit={handleResendFromEdit} onOpenSettings={onOpenSettings} + onSuggestionSelect={handleEmptyStateSuggestion} /> = { + morning: "chat.greetingMorning", + noon: "chat.greetingNoon", + afternoon: "chat.greetingAfternoon", + evening: "chat.greetingEvening", + night: "chat.greetingNight", +}; + +function resolveGreetingPeriod(hour: number): GreetingPeriod { + if (hour >= 5 && hour < 12) return "morning"; + if (hour >= 12 && hour < 14) return "noon"; + if (hour >= 14 && hour < 18) return "afternoon"; + if (hour >= 18 && hour < 23) return "evening"; + return "night"; +} + +function useGreetingPeriod() { + const [period, setPeriod] = useState(() => + resolveGreetingPeriod(new Date().getHours()), + ); + + useEffect(() => { + const timer = window.setInterval(() => { + setPeriod(resolveGreetingPeriod(new Date().getHours())); + }, 60_000); + return () => window.clearInterval(timer); + }, []); + + return period; +} + +const SUGGESTION_CARDS = [ + { + key: "explore", + icon: FolderTree, + accent: "199 89% 48%", + chipClassName: + "bg-sky-500/10 text-sky-600 group-hover:bg-sky-500/20 dark:bg-sky-400/10 dark:text-sky-400 dark:group-hover:bg-sky-400/20", + titleKey: "chat.suggestExploreTitle", + hintKey: "chat.suggestExploreHint", + promptKey: "chat.suggestExplorePrompt", + }, + { + key: "fix", + icon: Wrench, + accent: "38 92% 50%", + chipClassName: + "bg-amber-500/10 text-amber-600 group-hover:bg-amber-500/20 dark:bg-amber-400/10 dark:text-amber-400 dark:group-hover:bg-amber-400/20", + titleKey: "chat.suggestFixTitle", + hintKey: "chat.suggestFixHint", + promptKey: "chat.suggestFixPrompt", + }, + { + key: "ideate", + icon: Lightbulb, + accent: "160 84% 39%", + chipClassName: + "bg-emerald-500/10 text-emerald-600 group-hover:bg-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-400 dark:group-hover:bg-emerald-400/20", + titleKey: "chat.suggestIdeateTitle", + hintKey: "chat.suggestIdeateHint", + promptKey: "chat.suggestIdeatePrompt", + }, +] as const; + +export type ChatEmptyStateProps = { + variant: "no-models" | "start-chat"; + onOpenSettings?: (section?: SectionId) => void; + onSuggestionSelect?: (text: string) => void; +}; + +export function ChatEmptyState({ + variant, + onOpenSettings, + onSuggestionSelect, +}: ChatEmptyStateProps) { + const { t } = useLocale(); + const period = useGreetingPeriod(); + + // Drives the accent spotlight that follows the cursor inside each card. + const handleCardPointerMove = useCallback((event: ReactPointerEvent) => { + const card = event.currentTarget; + const rect = card.getBoundingClientRect(); + card.style.setProperty("--spot-x", `${event.clientX - rect.left}px`); + card.style.setProperty("--spot-y", `${event.clientY - rect.top}px`); + }, []); + + return ( +
+
+ + + {variant === "no-models" ? ( + <> +
+ {t("chat.welcome")} +
+
+ {t("chat.noModelSelected")} +
+
+ {t("chat.configureModel")} +
+ {onOpenSettings ? ( + + ) : null} + + ) : ( + <> +
+ {t(GREETING_KEYS[period])} +
+
+
+ {onSuggestionSelect ? ( +
+ {SUGGESTION_CARDS.map((card, index) => ( + + ))} +
+ ) : null} + + )} +
+ ); +} diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index e5c2068e..0f882b83 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -9,11 +9,11 @@ import { } from "react"; import { createPortal } from "react-dom"; -import iconSimpleUrl from "../../../../src-tauri/icons/icon-simple.png"; -import { Copy, Settings } from "../../../components/icons"; +import { Copy } from "../../../components/icons"; import { ScrollArea } from "../../../components/ui/scroll-area"; import { useLocale } from "../../../i18n"; import { resolveScrollViewport } from "../utils/chatScrollViewport"; +import { ChatEmptyState } from "./ChatEmptyState"; import { TranscriptHistory } from "./TranscriptHistory"; import { TranscriptLiveState } from "./TranscriptLiveState"; import { HistorySwitchLoadingOverlay } from "./TranscriptLoadingStates"; @@ -48,8 +48,9 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript setCopiedMessageKey, onResendFromEdit, onOpenSettings, + onSuggestionSelect, } = props; - const { t, locale } = useLocale(); + const { locale } = useLocale(); const showNoModelsState = !hasModels; const showStartChatState = hasModels && historyItems.length === 0 && !isSending; const shouldReserveTranscriptBottomSpace = !(showNoModelsState || showStartChatState); @@ -155,58 +156,13 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript >
- {showNoModelsState ? ( + {showNoModelsState || showStartChatState ? (
-
-
- -
-

- {t("chat.welcome")} -

-

- {t("chat.noModelSelected")} -

-

- {t("chat.configureModel")} -

- -
-
- ) : showStartChatState ? ( -
-
-
- -
- -

- {t("chat.startChat")} -

- -

- {t("chat.startChatDesc")} -

-
+
) : null} diff --git a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts index 32ea2711..c7dc66b4 100644 --- a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts +++ b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts @@ -33,6 +33,7 @@ export type ChatTranscriptProps = { attachments: PendingUploadedFile[], ) => void; onOpenSettings: (section?: SectionId) => void; + onSuggestionSelect?: (text: string) => void; }; export type TranscriptHistoryProps = Pick<