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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user