feat(frontend): add canvas agent empty state with server-driven starters

Render a CanvasAgentEmptyState in the Agent panel that fetches localized
starter content from the new agent-starters endpoint, with local defaults
as fallback. Clicking a starter focuses the prompt composer (new focus
handle). Includes accompanying UI polish: side-nav cleanup, animated
prompt examples, and canvas panel spacing tweaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 22:19:22 +08:00
parent c94b163540
commit 71a1b3ace4
16 changed files with 472 additions and 77 deletions
+50 -10
View File
@@ -6,7 +6,7 @@ import { openBrandKit, openHome } from "@/application/route";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { BrandKit } from "@/domain/brandKit";
import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project, ShareAccess } from "@/domain/design";
import type { AgentContent, AgentMessage, AgentStarterConfig, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project, ShareAccess } from "@/domain/design";
import { useI18n, type Locale } from "@/i18n/i18n";
import { brandKitRepository, brandKitStorageScope } from "@/infrastructure/brandKitRepository";
import { designGateway } from "@/infrastructure/designGateway";
@@ -18,6 +18,8 @@ import { BrandKitSelector } from "@/ui/components/BrandKitSelector";
import { CanvasAnnotationPreview } from "@/ui/components/CanvasAnnotationPreview";
import { CanvasAnnotations } from "@/ui/components/CanvasAnnotations";
import { CanvasAgentComposer, composerImageSize, defaultComposerImageSettings, type ComposerImageSettings, type ComposerMode } from "@/ui/components/CanvasAgentComposer";
import { CanvasAgentEmptyState } from "@/ui/components/CanvasAgentEmptyState";
import { defaultAgentStarterConfig, normalizeAgentStarterConfig } from "@/ui/components/CanvasAgentEmptyState/defaults";
import { CanvasAgentTimeline } from "@/ui/components/CanvasAgentTimeline";
import { CanvasGeneratedFilesPanel, CanvasGrid, CanvasLayerPanel } from "@/ui/components/CanvasPanels";
import { CanvasMinimap } from "@/ui/components/CanvasMinimap";
@@ -85,8 +87,7 @@ const minCanvasZoom = 0.1;
const maxCanvasZoom = 2.8;
const defaultCanvasBackgroundColor = "#F7F7F4";
const canvasBackgroundSwatches = ["#F5F5F5", "#000000", "#FFFFFF", "#00F016", "#A855F7", "#E9D5FF"];
const generatedFilesPanelWidth = 408;
const layersPanelWidth = 274;
const canvasSidePanelWidth = 268;
const newAgentConversationId = "__new_agent_conversation__";
const canvasWheelGuardSelector = [
".ui-popover-content",
@@ -575,6 +576,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const { selectedId, selectedIds, setSelectedIds, selectedNodes, selectedNode, selectOnlyNode, selectNodes, toggleNodeSelection } = useCanvasSelection(nodes);
const [messages, setMessages] = useState<AgentMessage[]>([]);
const [prompt, setPrompt] = useState("");
const [agentStarterConfig, setAgentStarterConfig] = useState<AgentStarterConfig>(() => defaultAgentStarterConfig(t));
const [agentThreads, setAgentThreads] = useState<AgentThreadSummary[]>([]);
const [credits, setCredits] = useState(30);
const [assistantPanelOpen, setAssistantPanelOpen] = useState(canViewChat);
@@ -703,6 +705,21 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
.catch(() => undefined);
}, [isAuthenticated]);
useEffect(() => {
let active = true;
const fallback = defaultAgentStarterConfig(t);
setAgentStarterConfig(fallback);
designGateway
.getAgentStarterConfig(locale)
.then((config) => {
if (active) setAgentStarterConfig(normalizeAgentStarterConfig(config, fallback));
})
.catch(() => undefined);
return () => {
active = false;
};
}, [locale, t]);
useEffect(() => {
let active = true;
if (!isAuthenticated || !canManageBrandKit) {
@@ -1083,7 +1100,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
height: selectedTextNode.height * viewport.k
};
}, [selectedTextNode, viewport]);
const floatingToolbarLeftInset = generatedFilesPanelOpen ? generatedFilesPanelWidth : layersPanelOpen ? layersPanelWidth : 0;
const floatingToolbarLeftInset = generatedFilesPanelOpen || layersPanelOpen ? canvasSidePanelWidth : 0;
const insertCanvasImageReference = useCallback((node: CanvasNode, options: { focus?: boolean; pending?: boolean; replacePending?: boolean } = {}) => {
const reference = canvasNodeToReferenceImage(node);
if (!reference) return;
@@ -2640,6 +2657,13 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
activeAgentThreadIdRef.current = null;
};
const selectAgentStarterPrompt = (nextPrompt: string) => {
if (!canEdit) return;
setComposerMode("agent");
setPrompt(nextPrompt);
window.requestAnimationFrame(() => agentComposerRef.current?.focus());
};
const selectAgentThread = (threadId: string) => {
autoScrollAgentFeedRef.current = false;
selectedAgentThreadIdRef.current = threadId;
@@ -3477,6 +3501,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
);
}
const displayProjectTitle = localizedCanvasTitle(project.title, t("canvasUntitled"));
return (
<div className={`workspace-shell ${assistantPanelOpen && canViewChat ? "" : "assistant-panel-collapsed"} ${canEdit ? "" : "is-readonly"} ${canViewChat ? "" : "is-canvas-only"}`}>
<header className="workspace-topbar">
@@ -3512,9 +3538,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
onDrop={canEdit ? handleCanvasDrop : undefined}
>
<input ref={canvasImageInputRef} type="file" accept="image/*" multiple hidden disabled={!canEdit} onChange={handleCanvasImageInput} />
<div className="workspace-stage-title">
<div className={`workspace-stage-title ${generatedFilesPanelOpen || layersPanelOpen ? "is-offset-by-canvas-panel" : ""}`}>
<WorkspaceTitle
title={project.title}
title={displayProjectTitle}
untitledLabel={t("canvasUntitled")}
renameLabel={t("renameCanvasTitle")}
placeholder={t("projectTitlePlaceholder")}
@@ -4011,7 +4037,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
<aside className="assistant-panel">
<div className="assistant-header">
<div className="assistant-header-title">
<strong>{agentHeaderTitle(messages, project, t("canvasUntitled"))}</strong>
<strong>{agentHeaderTitle(messages, project, displayProjectTitle)}</strong>
</div>
<div className="assistant-header-actions">
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!canEdit || !hasCurrentConversation}>
@@ -4098,7 +4124,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
</div>
<div className="assistant-feed" ref={assistantFeedRef} onScroll={updateAssistantScrollButton}>
<CanvasAgentTimeline messages={messages} nodes={nodes} active={agentTimelineActive} />
{!hasCurrentConversation && !agentTimelineActive ? (
<CanvasAgentEmptyState config={agentStarterConfig} disabled={!canEdit} onSelectPrompt={selectAgentStarterPrompt} />
) : (
<CanvasAgentTimeline messages={messages} nodes={nodes} active={agentTimelineActive} />
)}
</div>
{showAssistantScrollButton && (
@@ -5616,11 +5646,21 @@ function agentThreadTitleText(content: string) {
return visiblePromptText(content);
}
function agentHeaderTitle(messages: AgentMessage[], project: Project, fallback: string) {
function agentHeaderTitle(messages: AgentMessage[], project: Project, displayProjectTitle: string) {
const firstUserMessage = messages.find((message) => message.role === "user");
const firstUserText = firstUserMessage ? visiblePromptText(firstUserMessage.content) : "";
if (firstUserText) return firstUserText;
return visiblePromptText(project.brief) || project.title.trim() || fallback;
const brief = visiblePromptText(project.brief);
return brief && !isDefaultCanvasTitle(brief) ? brief : displayProjectTitle;
}
function localizedCanvasTitle(title: string, fallback: string) {
const trimmedTitle = title.trim();
return !trimmedTitle || isDefaultCanvasTitle(trimmedTitle) ? fallback : trimmedTitle;
}
function isDefaultCanvasTitle(title: string) {
return ["Untitled", "Untitled design project", "未命名设计项目"].includes(title.trim());
}
function isProjectGenerating(project: Project) {