Commit 10c7988e authored by xuchentao's avatar xuchentao

feat: simplify version preview and release workflow

parent 2d6e5007
...@@ -35,11 +35,24 @@ export class BuildManager { ...@@ -35,11 +35,24 @@ export class BuildManager {
} }
async publishPreview(siteId: string, distPath: string): Promise<string> { async publishPreview(siteId: string, distPath: string): Promise<string> {
return this.publishTo(path.join(runtimePaths.previews, siteId), distPath); return this.publishAtomically(path.join(runtimePaths.previews, siteId), distPath);
} }
async publishProduction(siteId: string, distPath: string): Promise<string> { async publishProduction(siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.productionDir, siteId), distPath);
}
async ensureProductionPlaceholder(siteId: string, siteName: string): Promise<string> {
const target = path.join(config.productionDir, siteId); const target = path.join(config.productionDir, siteId);
const indexPath = path.join(target, "index.html");
if (await stat(indexPath).then((value) => value.isFile()).catch(() => false)) return target;
const safeName = siteName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
await mkdir(target, { recursive: true });
await writeFile(indexPath, `<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width"><title>${safeName}|尚未上线</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;color:#24242f;background:#f7f7fb;font-family:system-ui,"PingFang SC",sans-serif}.card{max-width:420px;padding:42px;text-align:center;background:white;border:1px solid #e8e5f1;border-radius:24px;box-shadow:0 22px 60px rgba(36,16,95,.09)}span{display:grid;place-items:center;width:48px;height:48px;margin:auto;color:white;background:linear-gradient(135deg,#7028ff,#536cff);border-radius:15px;font-weight:800}h1{margin:20px 0 10px;font-size:24px}p{margin:0;color:#777985;font-size:14px;line-height:1.8}</style></head><body><main class="card"><span>W</span><h1>${safeName}</h1><p>该官网尚未上线。<br>请先在测试环境确认效果后发布。</p></main></body></html>`, "utf8");
return target;
}
private async publishAtomically(target: string, distPath: string): Promise<string> {
const suffix = crypto.randomBytes(4).toString("hex"); const suffix = crypto.randomBytes(4).toString("hex");
const staging = target + ".next-" + suffix; const staging = target + ".next-" + suffix;
const previous = target + ".previous-" + suffix; const previous = target + ".previous-" + suffix;
...@@ -57,11 +70,4 @@ export class BuildManager { ...@@ -57,11 +70,4 @@ export class BuildManager {
await rm(previous, { recursive: true, force: true }); await rm(previous, { recursive: true, force: true });
return target; return target;
} }
private async publishTo(target: string, distPath: string): Promise<string> {
await rm(target, { recursive: true, force: true });
await mkdir(path.dirname(target), { recursive: true });
await cp(distPath, target, { recursive: true });
return target;
}
} }
...@@ -24,6 +24,12 @@ export class GitManager { ...@@ -24,6 +24,12 @@ export class GitManager {
return result.stdout.trim(); return result.stdout.trim();
} }
async previousCommit(projectPath: string, commit = "HEAD"): Promise<string | undefined> {
await this.assertCommit(projectPath, commit);
const result = await execa("git", ["rev-list", "--parents", "-n", "1", commit], { cwd: projectPath });
return result.stdout.trim().split(/\s+/)[1];
}
async history(projectPath: string): Promise<GitHistoryItem[]> { async history(projectPath: string): Promise<GitHistoryItem[]> {
const current = await this.currentCommit(projectPath); const current = await this.currentCommit(projectPath);
const result = await execa("git", ["log", "--date=iso-strict", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI%x1e", "-30"], { cwd: projectPath }); const result = await execa("git", ["log", "--date=iso-strict", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI%x1e", "-30"], { cwd: projectPath });
......
...@@ -14,7 +14,7 @@ export const chatSchema = z.object({ ...@@ -14,7 +14,7 @@ export const chatSchema = z.object({
message: z.string().trim().min(2).max(2000), message: z.string().trim().min(2).max(2000),
}); });
export const rollbackSchema = z.object({ export const versionCommitSchema = z.object({
commit: z.string().regex(/^[0-9a-f]{7,40}$/), commit: z.string().regex(/^[0-9a-f]{7,40}$/),
}); });
......
...@@ -8,7 +8,7 @@ import { BuildManager } from "./build/build-manager.js"; ...@@ -8,7 +8,7 @@ import { BuildManager } from "./build/build-manager.js";
import { config, runtimePaths } from "./config.js"; import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js"; import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js"; import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import { chatSchema, createSiteSchema, rollbackSchema } from "./schemas.js"; import { chatSchema, createSiteSchema, versionCommitSchema } from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js"; import { CreateSiteService } from "./sites/create-site.js";
import { SiteAgentService } from "./sites/site-agent-service.js"; import { SiteAgentService } from "./sites/site-agent-service.js";
import { SiteRepository } from "./sites/site-repository.js"; import { SiteRepository } from "./sites/site-repository.js";
...@@ -33,11 +33,12 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (reque ...@@ -33,11 +33,12 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (reque
const body = chatSchema.parse(request.body); return siteAgent.execute(request.params.siteId, body.message); const body = chatSchema.parse(request.body); return siteAgent.execute(request.params.siteId, body.message);
}); });
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (request) => versions.rebuild(request.params.siteId)); app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (request) => versions.rebuild(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", async (request) => {
const body = versionCommitSchema.parse(request.body); return versions.previewVersion(request.params.siteId, body.commit);
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => versions.publish(request.params.siteId)); app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => versions.publish(request.params.siteId));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId))); app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId)));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/rollback", async (request) => { app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/undo", async (request) => versions.undo(request.params.siteId));
const body = rollbackSchema.parse(request.body); return versions.rollback(request.params.siteId, body.commit);
});
app.setErrorHandler((error, _request, reply) => { app.setErrorHandler((error, _request, reply) => {
const isMissingFile = error instanceof Error && "code" in error && error.code === "ENOENT"; const isMissingFile = error instanceof Error && "code" in error && error.code === "ENOENT";
...@@ -54,6 +55,7 @@ await rm(runtimePaths.builds, { recursive: true, force: true }); ...@@ -54,6 +55,7 @@ await rm(runtimePaths.builds, { recursive: true, force: true });
await mkdir(runtimePaths.builds, { recursive: true }); await mkdir(runtimePaths.builds, { recursive: true });
for (const site of await sites.list()) { for (const site of await sites.list()) {
await git.pruneWorktrees(sites.getProjectPath(site.siteId)); await git.pruneWorktrees(sites.getProjectPath(site.siteId));
await builds.ensureProductionPlaceholder(site.siteId, site.name);
if (site.environmentVersion !== 3) { if (site.environmentVersion !== 3) {
await versions.rebuild(site.siteId).catch((error) => app.log.warn(error)); await versions.rebuild(site.siteId).catch((error) => app.log.warn(error));
continue; continue;
......
...@@ -48,7 +48,8 @@ export class CreateSiteService { ...@@ -48,7 +48,8 @@ export class CreateSiteService {
await this.sites.update(siteId, { currentCommit: commit }); await this.sites.update(siteId, { currentCommit: commit });
const published = await this.builds.publishPreview(siteId, dist); const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, previewPort); await this.previews.start(siteId, published, previewPort);
return await this.sites.update(siteId, { status: "ready", currentCommit: commit, lastError: undefined }); await this.builds.ensureProductionPlaceholder(siteId, input.name);
return await this.sites.update(siteId, { status: "ready", currentCommit: commit, previewCommit: commit, lastError: undefined });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: "failed", lastError: message }); await this.sites.update(siteId, { status: "failed", lastError: message });
......
...@@ -19,6 +19,9 @@ export class SiteAgentService { ...@@ -19,6 +19,9 @@ export class SiteAgentService {
async execute(siteId: string, message: string): Promise<ChatResult> { async execute(siteId: string, message: string): Promise<ChatResult> {
const site = await this.sites.get(siteId); const site = await this.sites.get(siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前测试环境正在预览历史版本,请先切换到最近保存版本再继续修改");
}
const projectPath = this.sites.getProjectPath(siteId); const projectPath = this.sites.getProjectPath(siteId);
const taskId = "task_" + crypto.randomBytes(4).toString("hex"); const taskId = "task_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace"); const workspace = path.join(runtimePaths.builds, taskId, "workspace");
...@@ -49,11 +52,14 @@ export class SiteAgentService { ...@@ -49,11 +52,14 @@ export class SiteAgentService {
const commit = await this.git.cherryPick(projectPath, worktreeCommit); const commit = await this.git.cherryPick(projectPath, worktreeCommit);
const published = await this.builds.publishPreview(siteId, dist); const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort); await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: commit, environmentVersion: 3, lastError: undefined }); await this.sites.update(siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: site.currentCommit, environmentVersion: 3, lastError: undefined,
});
return { summary: generated.patch.summary, commit, previewUrl: site.previewUrl, changedFiles, mode: generated.mode }; return { summary: generated.patch.summary, commit, previewUrl: site.previewUrl, changedFiles, mode: generated.mode };
} catch (error) { } catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error); const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: "failed", lastError: details }); await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error; throw error;
} finally { } finally {
await this.git.removeWorktree(projectPath, workspace); await this.git.removeWorktree(projectPath, workspace);
......
...@@ -30,7 +30,8 @@ export class SiteRepository { ...@@ -30,7 +30,8 @@ export class SiteRepository {
return { return {
...stored, ...stored,
previewUrl: getPublicPreviewUrl(siteId), previewUrl: getPublicPreviewUrl(siteId),
productionUrl: stored.publishedCommit ? getPublicProductionUrl(siteId) : undefined, previewCommit: stored.previewCommit || stored.currentCommit,
productionUrl: getPublicProductionUrl(siteId),
publishStatus: stored.publishStatus || (stored.publishedCommit ? "published" : "unpublished"), publishStatus: stored.publishStatus || (stored.publishedCommit ? "published" : "unpublished"),
}; };
} }
......
import crypto from "node:crypto"; import crypto from "node:crypto";
import path from "node:path"; import path from "node:path";
import type { PublishResult } from "@webagent/shared"; import type { PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
import { getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js"; import { getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js";
import { BuildError, BuildManager } from "../build/build-manager.js"; import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js"; import { GitManager } from "../git/git-manager.js";
...@@ -15,42 +15,53 @@ export class SiteVersionService { ...@@ -15,42 +15,53 @@ export class SiteVersionService {
) {} ) {}
async rebuild(siteId: string): Promise<{ previewUrl: string }> { async rebuild(siteId: string): Promise<{ previewUrl: string }> {
const site = await this.sites.get(siteId);
const project = this.sites.getProjectPath(siteId); const project = this.sites.getProjectPath(siteId);
const site = await this.sites.get(siteId);
let currentCommit = await this.git.currentCommit(project);
let previewCommit = site.previewCommit || site.currentCommit || currentCommit;
if (await ensureEnvironmentConfig(project)) { if (await ensureEnvironmentConfig(project)) {
await this.git.commit(project, "System: support preview and production paths"); currentCommit = await this.git.commit(project, "System: support preview and production paths");
} if (previewCommit === site.currentCommit) previewCommit = currentCommit;
await this.sites.update(siteId, { status: "building", lastError: undefined });
try {
const dist = await this.builds.build(project, "rebuild_" + siteId, getPublicPreviewUrl(siteId));
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: await this.git.currentCommit(project), environmentVersion: 3 });
return { previewUrl: site.previewUrl };
} catch (error) {
await this.sites.update(siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) });
throw error;
} }
await this.buildPreview(site, previewCommit, "rebuild", { currentCommit });
return { previewUrl: site.previewUrl };
} }
async rollback(siteId: string, targetCommit: string): Promise<{ commit: string; previewUrl: string }> { async previewVersion(siteId: string, targetCommit: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId); const site = await this.sites.get(siteId);
const project = this.sites.getProjectPath(siteId); const project = this.sites.getProjectPath(siteId);
await this.git.assertCommit(project, targetCommit); await this.git.assertCommit(project, targetCommit);
const taskId = "rollback_" + crypto.randomBytes(4).toString("hex"); await this.buildPreview(site, targetCommit, "preview_version");
return { commit: targetCommit, previewUrl: site.previewUrl };
}
async undo(siteId: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId);
const project = this.sites.getProjectPath(siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前正在查看历史版本,请先切换回最近保存版本再撤销修改");
}
const currentCommit = await this.git.currentCommit(project);
const targetCommit = site.previousCommit || await this.git.previousCommit(project, currentCommit);
if (!targetCommit) throw new Error("当前没有可撤销的上一版本");
await this.git.assertCommit(project, targetCommit);
const taskId = "undo_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace"); const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(siteId, { status: "building", lastError: undefined }); await this.sites.update(siteId, { status: "building", lastError: undefined });
try { try {
await this.git.createWorktree(project, workspace, targetCommit); await this.git.createWorktree(project, workspace, targetCommit);
await ensureEnvironmentConfig(workspace); await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId)); const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId));
const published = await this.builds.publishPreview(siteId, dist);
const commit = await this.git.restoreAsCommit(project, targetCommit); const commit = await this.git.restoreAsCommit(project, targetCommit);
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort); await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: commit, environmentVersion: 3, lastError: undefined }); await this.sites.update(siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: undefined, environmentVersion: 3, lastError: undefined,
});
return { commit, previewUrl: site.previewUrl }; return { commit, previewUrl: site.previewUrl };
} catch (error) { } catch (error) {
await this.sites.update(siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) }); await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: error instanceof Error ? error.message : String(error) });
throw error; throw error;
} finally { await this.git.removeWorktree(project, workspace); } } finally { await this.git.removeWorktree(project, workspace); }
} }
...@@ -63,8 +74,10 @@ export class SiteVersionService { ...@@ -63,8 +74,10 @@ export class SiteVersionService {
const workspace = path.join(runtimePaths.builds, taskId, "workspace"); const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(siteId, { publishStatus: "publishing", lastPublishError: undefined }); await this.sites.update(siteId, { publishStatus: "publishing", lastPublishError: undefined });
try { try {
const commit = await this.git.currentCommit(project); const commit = site.previewCommit || site.currentCommit;
await this.git.assertCommit(project, commit);
await this.git.createWorktree(project, workspace, commit); await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicProductionUrl(siteId)); const dist = await this.builds.build(workspace, taskId, getPublicProductionUrl(siteId));
await this.builds.publishProduction(siteId, dist); await this.builds.publishProduction(siteId, dist);
const publishedAt = new Date().toISOString(); const publishedAt = new Date().toISOString();
...@@ -82,4 +95,34 @@ export class SiteVersionService { ...@@ -82,4 +95,34 @@ export class SiteVersionService {
await this.git.removeWorktree(project, workspace); await this.git.removeWorktree(project, workspace);
} }
} }
private async buildPreview(
site: SiteInfo,
commit: string,
taskPrefix: string,
metadata: Partial<SiteInfo> = {},
): Promise<void> {
const project = this.sites.getProjectPath(site.siteId);
await this.git.assertCommit(project, commit);
const taskId = taskPrefix + "_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(site.siteId, { status: "building", lastError: undefined });
try {
await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(site.siteId));
const published = await this.builds.publishPreview(site.siteId, dist);
await this.previews.start(site.siteId, published, site.previewPort);
await this.sites.update(site.siteId, {
...metadata,
status: "ready", previewCommit: commit, environmentVersion: 3, lastError: undefined,
});
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(site.siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error;
} finally {
await this.git.removeWorktree(project, workspace);
}
}
} }
...@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; ...@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowRight, Bot, Check, ChevronDown, Clock3, Code2, ExternalLink, ArrowLeft, ArrowRight, Bot, Check, ChevronDown, Clock3, Code2, ExternalLink,
Globe2, History, Laptop, LoaderCircle, MessageSquareText, Monitor, Palette, Globe2, History, Laptop, LoaderCircle, MessageSquareText, Monitor, Palette,
Plus, RefreshCw, Rocket, RotateCcw, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Plus, RefreshCw, Rocket, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Undo2,
WandSparkles, X, WandSparkles, X,
} from "lucide-react"; } from "lucide-react";
import type { CreateSiteInput, SiteInfo } from "@webagent/shared"; import type { CreateSiteInput, SiteInfo } from "@webagent/shared";
...@@ -156,6 +156,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -156,6 +156,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
const [tab, setTab] = useState<"chat" | "history">("chat"); const [tab, setTab] = useState<"chat" | "history">("chat");
const [device, setDevice] = useState<"desktop" | "mobile">("desktop"); const [device, setDevice] = useState<"desktop" | "mobile">("desktop");
const [environment, setEnvironment] = useState<"preview" | "production">("preview"); const [environment, setEnvironment] = useState<"preview" | "production">("preview");
const [pendingPreviewCommit, setPendingPreviewCommit] = useState("");
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [iframeVersion, setIframeVersion] = useState(0); const [iframeVersion, setIframeVersion] = useState(0);
const [messages, setMessages] = useState<ChatMessage[]>([ const [messages, setMessages] = useState<ChatMessage[]>([
...@@ -163,9 +164,11 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -163,9 +164,11 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
]); ]);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const site = siteQuery.data; const site = siteQuery.data;
const historicalPreview = Boolean(site?.previewCommit && site.previewCommit !== site.currentCommit);
useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages]);
useEffect(() => { useEffect(() => {
setEnvironment("preview"); setEnvironment("preview");
setPendingPreviewCommit("");
setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]); setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]);
}, [siteId, agentMode]); }, [siteId, agentMode]);
...@@ -195,9 +198,25 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -195,9 +198,25 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
}, },
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "重新构建失败,原测试预览保持可用" }]), onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "重新构建失败,原测试预览保持可用" }]),
}); });
const rollbackMutation = useMutation({ const previewVersionMutation = useMutation({
mutationFn: (commit: string) => api.rollback(siteId, commit), mutationFn: (commit: string) => api.previewVersion(siteId, commit),
onSuccess: async (result) => { setMessages((items) => [...items, { role: "agent", text: "已恢复所选版本,并创建新的回滚记录。", meta: result.commit.slice(0, 7) }]); await refreshData(); }, onMutate: (commit) => setPendingPreviewCommit(commit),
onSuccess: async (result) => {
setPendingPreviewCommit("");
setEnvironment("preview");
await refreshData();
setMessages((items) => [...items, { role: "agent", text: "已切换测试预览版本。", meta: "测试版本 · " + result.commit.slice(0, 7) }]);
},
onError: (error) => { setPendingPreviewCommit(""); setMessages((items) => [...items, { role: "agent", text: error.message, meta: "版本预览失败,原测试版本保持可用" }]); },
});
const undoMutation = useMutation({
mutationFn: () => api.undo(siteId),
onSuccess: async (result) => {
setEnvironment("preview");
setMessages((items) => [...items, { role: "agent", text: "已撤销最近一次修改。", meta: "测试环境已恢复 · " + result.commit.slice(0, 7) }]);
await refreshData();
},
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "撤销失败,现有测试和线上版本保持可用" }]),
}); });
const publishMutation = useMutation({ const publishMutation = useMutation({
mutationFn: () => api.publish(siteId), mutationFn: () => api.publish(siteId),
...@@ -209,21 +228,27 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -209,21 +228,27 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "发布失败,原生产版本保持可用" }]), onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "发布失败,原生产版本保持可用" }]),
}); });
const send = (message = input) => { const send = (message = input) => {
const value = message.trim(); if (!value || chatMutation.isPending) return; const value = message.trim(); if (!value || chatMutation.isPending || historicalPreview) return;
setMessages((items) => [...items, { role: "user", text: value }]); setInput(""); chatMutation.mutate(value); setMessages((items) => [...items, { role: "user", text: value }]); setInput(""); chatMutation.mutate(value);
}; };
const busy = chatMutation.isPending || rebuildMutation.isPending || rollbackMutation.isPending || publishMutation.isPending; const busy = chatMutation.isPending || rebuildMutation.isPending || previewVersionMutation.isPending || undoMutation.isPending || publishMutation.isPending;
const productionCurrent = Boolean(site?.publishedCommit && site.publishedCommit === site.currentCommit);
const canPublish = site?.status === "ready" && !productionCurrent;
if (!site) return <Splash />; if (!site) return <Splash />;
const previewVersion = historyQuery.data?.find((item) => item.hash === site.currentCommit); const previewVersion = historyQuery.data?.find((item) => item.hash === site.previewCommit);
const currentVersion = historyQuery.data?.find((item) => item.hash === site.currentCommit);
const productionVersion = historyQuery.data?.find((item) => item.hash === site.publishedCommit); const productionVersion = historyQuery.data?.find((item) => item.hash === site.publishedCommit);
const productionMatchesPreview = Boolean(site.publishedCommit && site.publishedCommit === site.previewCommit);
const canPublish = site.status === "ready" && Boolean(site.previewCommit) && !productionMatchesPreview;
const canUndo = !historicalPreview && Boolean(site.previousCommit || currentVersion?.message.startsWith("Agent:"));
const showingProduction = environment === "production"; const showingProduction = environment === "production";
const activeUrl = showingProduction ? site.productionUrl : site.previewUrl; const activeUrl = showingProduction ? site.productionUrl : site.previewUrl;
const environmentStatus = showingProduction const environmentStatus = showingProduction
? !site.productionUrl ? "尚未上线" : productionCurrent ? "生产已同步" : "生产版本较旧" ? site.lastPublishError ? "上线失败 · 线上未变" : !site.publishedCommit ? "尚未上线" : productionMatchesPreview ? "生产已同步" : "生产版本较旧"
: site.status === "ready" ? "测试已同步" : "需要检查"; : site.lastError ? "构建失败 · 预览未变" : site.status === "ready" ? "测试已同步" : "需要检查";
const viewingLabel = showingProduction
? site.publishedCommit ? `线上版本 ${site.publishedCommit.slice(0, 7)}` : "尚未上线"
: historicalPreview ? `历史版本 ${site.previewCommit?.slice(0, 7)}` : `测试版本 ${site.previewCommit?.slice(0, 7) || "-------"}`;
const savedState = chatMutation.isPending ? "正在修改" : site.lastError ? "构建失败" : "已保存";
return <main className="workspace"> return <main className="workspace">
<aside className="rail"> <aside className="rail">
<Logo compact /> <Logo compact />
...@@ -243,42 +268,34 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -243,42 +268,34 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
{chatMutation.isPending && <div className="agent-progress"><span className="agent-avatar"><Sparkles size={14} /></span><div><p><LoaderCircle className="spin" size={14} /> Agent 正在设计和验证</p><div className="progress-track"><i /></div><small>读取文件 → 生成 Patch → 构建检查</small></div></div>} {chatMutation.isPending && <div className="agent-progress"><span className="agent-avatar"><Sparkles size={14} /></span><div><p><LoaderCircle className="spin" size={14} /> Agent 正在设计和验证</p><div className="progress-track"><i /></div><small>读取文件 → 生成 Patch → 构建检查</small></div></div>}
{messages.length <= 1 && <div className="suggestions"><span>试试这些修改</span>{["把网站主色改成深蓝色", "增加三个服务卡片", "增加一个企业优势"].map((text) => <button key={text} onClick={() => send(text)}><Sparkles size={12} />{text}</button>)}</div>} {messages.length <= 1 && <div className="suggestions"><span>试试这些修改</span>{["把网站主色改成深蓝色", "增加三个服务卡片", "增加一个企业优势"].map((text) => <button key={text} onClick={() => send(text)}><Sparkles size={12} />{text}</button>)}</div>}
</div> </div>
{historicalPreview && <div className="history-edit-lock"><div><History size={14} /><span>当前是只读历史预览;切换到“最近保存”版本后才能继续修改</span></div></div>}
<div className="composer"> <div className="composer">
<textarea rows={3} value={input} disabled={busy} placeholder="描述你想要的修改,例如:让首屏更有科技感…" onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} /> <textarea rows={3} value={input} disabled={busy || historicalPreview} placeholder={historicalPreview ? "切换到最近保存版本后才能继续修改…" : "描述你想要的修改,例如:让首屏更有科技感…"} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} />
<div><span>{agentMode === "model" ? <><span className="online-dot" />模型 Agent</> : <><Code2 size={12} />本地演示模式</>}</span><button onClick={() => send()} disabled={!input.trim() || busy}><Send size={15} /></button></div> <div><span>{agentMode === "model" ? <><span className="online-dot" />模型 Agent</> : <><Code2 size={12} />本地演示模式</>}</span><button onClick={() => send()} disabled={!input.trim() || busy || historicalPreview}><Send size={15} /></button></div>
</div> </div>
</> : <div className="history-list"> </> : <div className="history-list">
<div className="environment-versions"> <div className="history-heading"><div><h3>测试版本</h3><p>点击右侧“预览”切换测试版本,生产环境不会改变</p></div><span>{historyQuery.data?.length || 0}</span></div>
<button className={environment === "preview" ? "active preview" : "preview"} onClick={() => setEnvironment("preview")}> {historicalPreview && <div className="history-edit-lock history-panel-lock"><div><History size={14} /><span>历史版本只能预览或上线;切换到“最近保存”版本后才能继续修改</span></div></div>}
<span><Monitor size={14} />测试环境<em>当前预览</em></span>
<strong>{site.currentCommit.slice(0, 7)}</strong>
<small>{previewVersion ? formatDate(previewVersion.date) : formatDate(site.updatedAt)}</small>
</button>
<button className={environment === "production" ? "active production" : "production"} onClick={() => setEnvironment("production")}>
<span><Globe2 size={14} />生产环境<em>{site.productionUrl ? productionCurrent ? "已同步" : "待更新" : "未上线"}</em></span>
<strong>{site.publishedCommit?.slice(0, 7) || "-------"}</strong>
<small>{site.publishedAt ? formatDate(site.publishedAt) : "发布后生成生产链接"}</small>
</button>
</div>
<div className="history-heading"><div><h3>历史版本</h3><p>选择旧版本可恢复到测试环境</p></div><span>{historyQuery.data?.length || 0}</span></div>
{historyQuery.data?.map((item, index) => { {historyQuery.data?.map((item, index) => {
const isPreview = item.hash === site.currentCommit; const isPreview = item.hash === site.previewCommit;
const isProduction = item.hash === site.publishedCommit; const isProduction = item.hash === site.publishedCommit;
return <article className={isPreview || isProduction ? "environment-version" : ""} key={item.hash}> const isCurrent = item.hash === site.currentCommit;
<div className="timeline"><i>{item.current ? <Check size={10} /> : ""}</i>{index < (historyQuery.data?.length || 0) - 1 && <span />}</div> const selecting = pendingPreviewCommit === item.hash;
<div className="commit-info"><div><strong>{item.message}</strong>{isPreview && <em className="preview-badge">测试</em>}{isProduction && <em className="production-badge">生产</em>}</div><p><code>{item.shortHash}</code><span>·</span>{formatDate(item.date)}</p></div> return <article className={isPreview ? "selected" : ""} key={item.hash}>
<div className="history-actions"> <div className="history-row" aria-current={isPreview ? "page" : undefined}>
{isPreview && <button title="查看测试环境" onClick={() => setEnvironment("preview")}><Monitor size={13} /></button>} <span className="timeline"><i>{selecting ? <LoaderCircle className="spin" size={10} /> : isPreview ? <Check size={10} /> : ""}</i>{index < (historyQuery.data?.length || 0) - 1 && <span />}</span>
{isProduction && <button title="查看生产环境" onClick={() => setEnvironment("production")}><Globe2 size={13} /></button>} <span className="commit-info"><span><strong>{item.message}</strong>{isPreview && <em className="preview-badge">当前查看</em>}{isProduction && <em className="production-badge">线上版本</em>}{isCurrent && <em className="current-badge">最近保存</em>}</span><small><code>{item.shortHash}</code><span>·</span>{formatDate(item.date)}</small></span>
{!isPreview && <button title="恢复到测试环境" disabled={busy} onClick={() => { if (window.confirm("确定将测试环境恢复到版本 " + item.shortHash + " 吗?生产环境不会受到影响。")) rollbackMutation.mutate(item.hash); }}><RotateCcw size={13} /></button>} {isPreview
</div> ? <span className="history-row-state">正在查看</span>
</article>; : <button className="history-preview-button" type="button" disabled={busy} onClick={() => { setEnvironment("preview"); previewVersionMutation.mutate(item.hash); }}>{selecting ? <LoaderCircle className="spin" size={10} /> : <Monitor size={11} />}{selecting ? "切换中" : "预览"}</button>}
</div>
</article>;
})} })}
</div>} </div>}
</section> </section>
<section className="preview-shell"> <section className="preview-shell">
<header className="preview-toolbar"> <header className="preview-toolbar">
<div className="preview-title"><div><span className={`status-dot ${showingProduction ? "production" : ""}`} /><strong>{site.name}</strong><em className={showingProduction ? "production" : ""}>{showingProduction ? "生产环境" : "测试环境"}</em></div><span className={`build-status ${showingProduction ? site.publishStatus : site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : <Check size={12} />}{publishMutation.isPending ? "正在发布" : rebuildMutation.isPending ? "正在构建" : environmentStatus}</span></div> <div className="preview-title"><div><span className={`status-dot ${showingProduction ? "production" : ""}`} /><strong>{site.name}</strong><em className={showingProduction ? "production" : ""}>{showingProduction ? "生产环境" : "测试环境"}</em></div><span className={`build-status ${showingProduction ? site.lastPublishError ? "failed" : site.publishStatus : site.lastError ? "failed" : site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : showingProduction ? site.lastPublishError ? <X size={12} /> : <Check size={12} /> : site.lastError ? <X size={12} /> : <Check size={12} />}{publishMutation.isPending ? "正在发布" : chatMutation.isPending || rebuildMutation.isPending || previewVersionMutation.isPending || undoMutation.isPending ? "正在构建" : environmentStatus}</span></div>
<div className="preview-modes"> <div className="preview-modes">
<div className="environment-toggle"><button className={!showingProduction ? "active" : ""} onClick={() => setEnvironment("preview")}><Monitor size={13} />测试预览</button><button className={showingProduction ? "active" : ""} onClick={() => setEnvironment("production")}><Globe2 size={13} />生产站点</button></div> <div className="environment-toggle"><button className={!showingProduction ? "active" : ""} onClick={() => setEnvironment("preview")}><Monitor size={13} />测试预览</button><button className={showingProduction ? "active" : ""} onClick={() => setEnvironment("production")}><Globe2 size={13} />生产站点</button></div>
<div className="device-toggle"><button className={device === "desktop" ? "active" : ""} onClick={() => setDevice("desktop")}><Laptop size={15} /></button><button className={device === "mobile" ? "active" : ""} onClick={() => setDevice("mobile")}><Smartphone size={15} /></button></div> <div className="device-toggle"><button className={device === "desktop" ? "active" : ""} onClick={() => setDevice("desktop")}><Laptop size={15} /></button><button className={device === "mobile" ? "active" : ""} onClick={() => setDevice("mobile")}><Smartphone size={15} /></button></div>
...@@ -286,16 +303,22 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -286,16 +303,22 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
<div className="toolbar-actions"> <div className="toolbar-actions">
{!showingProduction && <button className="rebuild-button" disabled={busy} onClick={() => rebuildMutation.mutate()} title="重新构建测试环境"><RefreshCw className={rebuildMutation.isPending ? "spin" : ""} size={14} /><span>重新构建</span></button>} {!showingProduction && <button className="rebuild-button" disabled={busy} onClick={() => rebuildMutation.mutate()} title="重新构建测试环境"><RefreshCw className={rebuildMutation.isPending ? "spin" : ""} size={14} /><span>重新构建</span></button>}
<a className="environment-link preview-link" href={site.previewUrl} target="_blank" rel="noreferrer" title="打开测试环境"><Monitor size={13} /><span>测试链接</span></a> <a className="environment-link preview-link" href={site.previewUrl} target="_blank" rel="noreferrer" title="打开测试环境"><Monitor size={13} /><span>测试链接</span></a>
{site.productionUrl ? <a className="environment-link production-link" href={site.productionUrl} target="_blank" rel="noreferrer" title="打开生产环境"><Globe2 size={13} /><span>生产链接</span></a> : <button className="environment-link production-link" disabled title="发布上线后生成生产链接"><Globe2 size={13} /><span>生产链接</span></button>} <a className="environment-link production-link" href={site.productionUrl} target="_blank" rel="noreferrer" title="打开生产环境"><Globe2 size={13} /><span>生产链接</span></a>
<button className="publish-button" disabled={busy || !canPublish} onClick={() => { if (window.confirm("确定将当前测试版本发布到生产环境吗?")) publishMutation.mutate(); }} title={productionCurrent ? "当前版本已经上线" : site.status !== "ready" ? "测试环境构建成功后才能发布" : "发布当前测试版本到生产环境"}> {!showingProduction && <button className="undo-button" disabled={busy || !canUndo} onClick={() => { if (window.confirm("确定撤销最近一次修改吗?测试环境将恢复到上一份已保存版本,线上版本不会改变。")) undoMutation.mutate(); }} title={historicalPreview ? "请先切换回最近保存版本" : canUndo ? "恢复上一份已保存版本" : "当前没有可撤销的修改"}><Undo2 size={14} /><span>撤销最近修改</span></button>}
{publishMutation.isPending ? <LoaderCircle className="spin" size={14} /> : <Rocket size={14} />}<span>{productionCurrent ? "已上线" : site.productionUrl ? "发布更新" : "发布上线"}</span> {!showingProduction && <button className="publish-button" disabled={busy || !canPublish} onClick={() => { if (window.confirm("确定将当前测试预览版本发布到生产环境吗?")) publishMutation.mutate(); }} title={productionMatchesPreview ? "当前测试版本已经上线" : site.status !== "ready" ? "测试环境构建成功后才能发布" : "发布当前测试预览版本到生产环境"}>
</button> {publishMutation.isPending ? <LoaderCircle className="spin" size={14} /> : <Rocket size={14} />}<span>{productionMatchesPreview ? "已上线" : "上线当前版本"}</span>
</button>}
</div> </div>
</header> </header>
<section className="version-state-strip">
<article><span>当前正在看</span><strong>{viewingLabel}</strong><small>{showingProduction ? "生产链接" : historicalPreview ? "只读测试预览" : "测试链接"}</small></article>
<article className={site.lastError ? "error" : ""}><span>最近保存 <em>{savedState}</em></span><strong>{site.currentCommit.slice(0, 7)}</strong><small>{site.lastError ? "修改失败,已保留原测试版本" : "最近一次成功保存的可编辑版本"}</small></article>
<article className="production"><span>线上运行</span><strong>{site.publishedCommit?.slice(0, 7) || "尚未上线"}</strong><small>{site.lastPublishError ? "上线失败,原线上版本保持不变" : site.publishedAt ? formatDate(site.publishedAt) : "生产链接已保留"}</small></article>
</section>
<div className="browser-stage"> <div className="browser-stage">
<div className={`browser-frame ${device}`}> <div className={`browser-frame ${device}`}>
<div className="browser-chrome"><div><i /><i /><i /></div><span>{activeUrl || site.productionUrl || "/sites/" + site.siteId + "/"}</span><RefreshCw size={11} /></div> <div className="browser-chrome"><div><i /><i /><i /></div><span>{activeUrl || site.productionUrl || "/sites/" + site.siteId + "/"}</span><RefreshCw size={11} /></div>
{activeUrl ? <iframe key={environment + iframeVersion} title={site.name + (showingProduction ? " 生产站点" : " 测试预览")} src={activeUrl + "?v=" + iframeVersion} /> : <div className="production-empty"><span><Globe2 size={24} /></span><strong>生产环境尚未发布</strong><p>确认测试效果后,点击“发布上线”生成独立生产版本和链接。</p><button disabled={!canPublish || busy} onClick={() => { if (window.confirm("确定将当前测试版本发布到生产环境吗?")) publishMutation.mutate(); }}><Rocket size={14} />发布当前测试版本</button></div>} <iframe key={environment + iframeVersion} title={site.name + (showingProduction ? " 生产站点" : " 测试预览")} src={activeUrl + "?v=" + iframeVersion} />
{busy && <div className="preview-busy"><LoaderCircle className="spin" size={25} /><strong>{publishMutation.isPending ? "正在发布生产版本" : "正在生成测试版本"}</strong><span>{publishMutation.isPending ? "测试环境和原生产版本保持可用" : "当前测试预览保持可用"}</span></div>} {busy && <div className="preview-busy"><LoaderCircle className="spin" size={25} /><strong>{publishMutation.isPending ? "正在发布生产版本" : "正在生成测试版本"}</strong><span>{publishMutation.isPending ? "测试环境和原生产版本保持可用" : "当前测试预览保持可用"}</span></div>}
</div> </div>
</div> </div>
......
import type { ChatResult, CreateSiteInput, GitHistoryItem, PublishResult, SiteInfo } from "@webagent/shared"; import type { ChatResult, CreateSiteInput, GitHistoryItem, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> { async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers); const headers = new Headers(options?.headers);
...@@ -19,7 +19,8 @@ export const api = { ...@@ -19,7 +19,8 @@ export const api = {
createSite: (input: CreateSiteInput) => request<SiteInfo>("/api/sites", { method: "POST", body: JSON.stringify(input) }), createSite: (input: CreateSiteInput) => request<SiteInfo>("/api/sites", { method: "POST", body: JSON.stringify(input) }),
chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }), chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }),
history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"), history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"),
previewVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-version", { method: "POST", body: JSON.stringify({ commit }) }),
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }), rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }), publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
rollback: (siteId: string, commit: string) => request<{ commit: string; previewUrl: string }>("/api/sites/" + siteId + "/rollback", { method: "POST", body: JSON.stringify({ commit }) }), undo: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/undo", { method: "POST" }),
}; };
...@@ -18,7 +18,9 @@ ...@@ -18,7 +18,9 @@
.chat-scroll{flex:1 1 0;min-height:0;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;padding:20px 17px 28px}.chat-date{text-align:center;color:#aaaeba;font-size:9px;margin-bottom:22px}.message{display:flex;gap:9px;margin-bottom:17px}.message.user{justify-content:flex-end}.message>div{max-width:86%}.message p{margin:0;padding:12px 14px;color:var(--text-2);background:#f6f6f9;border-radius:4px 13px 13px 13px;font-size:12px;line-height:1.65}.message.user p{color:white;background:var(--gradient);border-radius:13px 4px 13px 13px;box-shadow:0 8px 18px rgba(112,40,255,.15)}.message small{display:flex;align-items:center;gap:4px;margin:6px 2px 0;color:#9a9da8;font-size:9px}.agent-avatar{flex:0 0 auto;display:grid;place-items:center;width:28px;height:28px;color:var(--primary);background:var(--soft);border:1px solid #e2dbff;border-radius:9px}.agent-progress{display:flex;gap:9px;margin:8px 0 18px}.agent-progress>div{flex:1;padding:12px 13px;background:#faf9ff;border:1px solid #ebe6ff;border-radius:4px 13px 13px 13px}.agent-progress p{display:flex;align-items:center;gap:7px;margin:0 0 10px;color:var(--text-2);font-size:11px;font-weight:700}.agent-progress small{color:var(--muted);font-size:9px}.progress-track{height:3px;margin-bottom:8px;overflow:hidden;background:#ebe7f5;border-radius:9px}.progress-track i{display:block;width:45%;height:100%;background:var(--gradient);border-radius:9px;animation:progress 1.5s ease-in-out infinite}@keyframes progress{50%{transform:translateX(120%)}}.suggestions{display:flex;flex-direction:column;gap:7px;margin:25px 37px 0}.suggestions>span{margin-bottom:3px;color:var(--muted);font-size:9px}.suggestions button{display:flex;align-items:center;gap:7px;padding:9px 11px;text-align:left;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:9px;font-size:10px}.suggestions button:hover{color:var(--primary);border-color:#cdbdff;background:#faf9ff} .chat-scroll{flex:1 1 0;min-height:0;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;padding:20px 17px 28px}.chat-date{text-align:center;color:#aaaeba;font-size:9px;margin-bottom:22px}.message{display:flex;gap:9px;margin-bottom:17px}.message.user{justify-content:flex-end}.message>div{max-width:86%}.message p{margin:0;padding:12px 14px;color:var(--text-2);background:#f6f6f9;border-radius:4px 13px 13px 13px;font-size:12px;line-height:1.65}.message.user p{color:white;background:var(--gradient);border-radius:13px 4px 13px 13px;box-shadow:0 8px 18px rgba(112,40,255,.15)}.message small{display:flex;align-items:center;gap:4px;margin:6px 2px 0;color:#9a9da8;font-size:9px}.agent-avatar{flex:0 0 auto;display:grid;place-items:center;width:28px;height:28px;color:var(--primary);background:var(--soft);border:1px solid #e2dbff;border-radius:9px}.agent-progress{display:flex;gap:9px;margin:8px 0 18px}.agent-progress>div{flex:1;padding:12px 13px;background:#faf9ff;border:1px solid #ebe6ff;border-radius:4px 13px 13px 13px}.agent-progress p{display:flex;align-items:center;gap:7px;margin:0 0 10px;color:var(--text-2);font-size:11px;font-weight:700}.agent-progress small{color:var(--muted);font-size:9px}.progress-track{height:3px;margin-bottom:8px;overflow:hidden;background:#ebe7f5;border-radius:9px}.progress-track i{display:block;width:45%;height:100%;background:var(--gradient);border-radius:9px;animation:progress 1.5s ease-in-out infinite}@keyframes progress{50%{transform:translateX(120%)}}.suggestions{display:flex;flex-direction:column;gap:7px;margin:25px 37px 0}.suggestions>span{margin-bottom:3px;color:var(--muted);font-size:9px}.suggestions button{display:flex;align-items:center;gap:7px;padding:9px 11px;text-align:left;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:9px;font-size:10px}.suggestions button:hover{color:var(--primary);border-color:#cdbdff;background:#faf9ff}
.composer{flex:0 0 auto;margin:0 14px 14px;padding:10px;background:#fff;border:1px solid #dcd8e8;border-radius:13px;box-shadow:0 5px 22px rgba(36,16,95,.07)}.composer textarea{width:100%;padding:2px 4px;resize:none;color:var(--text);background:transparent;border:0;outline:none;font-size:11px;line-height:1.55}.composer textarea::placeholder{color:#a5a7b0}.composer>div{display:flex;align-items:center;justify-content:space-between}.composer>div>span{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:9px}.online-dot{width:6px;height:6px;border-radius:50%;background:#20b486}.composer button{display:grid;place-items:center;width:29px;height:29px;color:white;background:var(--gradient);border:0;border-radius:8px} .composer{flex:0 0 auto;margin:0 14px 14px;padding:10px;background:#fff;border:1px solid #dcd8e8;border-radius:13px;box-shadow:0 5px 22px rgba(36,16,95,.07)}.composer textarea{width:100%;padding:2px 4px;resize:none;color:var(--text);background:transparent;border:0;outline:none;font-size:11px;line-height:1.55}.composer textarea::placeholder{color:#a5a7b0}.composer>div{display:flex;align-items:center;justify-content:space-between}.composer>div>span{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:9px}.online-dot{width:6px;height:6px;border-radius:50%;background:#20b486}.composer button{display:grid;place-items:center;width:29px;height:29px;color:white;background:var(--gradient);border:0;border-radius:8px}
.history-list{flex:1 1 0;min-height:0;overflow:auto;padding:16px 16px 22px}.environment-versions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:22px}.environment-versions>button{min-width:0;padding:12px;text-align:left;background:#fafafd;border:1px solid var(--border);border-radius:12px}.environment-versions>button:hover,.environment-versions>button.active{border-color:#bca8ff;background:#faf8ff;box-shadow:0 5px 18px rgba(112,40,255,.08)}.environment-versions>button.production:hover,.environment-versions>button.production.active{border-color:#9bd8c6;background:#f4fcf9;box-shadow:0 5px 18px rgba(32,180,134,.08)}.environment-versions span{display:flex;align-items:center;gap:5px;color:var(--text-2);font-size:9px;font-weight:750}.environment-versions span>em{margin-left:auto;padding:2px 4px;color:var(--primary);background:var(--soft);border-radius:4px;font-size:7px;font-style:normal}.environment-versions .production span>em{color:#13765b;background:#e7f8f2}.environment-versions strong,.environment-versions small{display:block}.environment-versions strong{margin-top:13px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;letter-spacing:.02em}.environment-versions small{margin-top:5px;overflow:hidden;color:var(--muted);font-size:8px;white-space:nowrap;text-overflow:ellipsis}.history-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px}.history-heading h3{margin:0 0 4px;font-size:14px}.history-heading p{margin:0;color:var(--muted);font-size:9px}.history-heading>span{display:grid;place-items:center;width:25px;height:25px;color:var(--primary);background:var(--soft);border-radius:8px;font-size:10px;font-weight:800}.history-list article{display:grid;grid-template-columns:24px minmax(0,1fr) auto;gap:7px;min-height:69px}.history-list article.environment-version{margin:0 -5px;padding:0 5px;background:#fbfaff;border-radius:10px}.timeline{display:flex;align-items:center;flex-direction:column}.timeline i{display:grid;place-items:center;width:17px;height:17px;color:white;background:#d6d7df;border:3px solid white;box-shadow:0 0 0 1px #d6d7df;border-radius:50%;font-style:normal}.history-list article.environment-version .timeline i{background:var(--primary);box-shadow:0 0 0 1px var(--primary)}.timeline span{width:1px;flex:1;background:var(--divider)}.commit-info{min-width:0;padding-bottom:18px}.commit-info>div{display:flex;align-items:center;gap:5px;min-width:0}.commit-info strong{min-width:0;overflow:hidden;font-size:10px;line-height:1.4;white-space:nowrap;text-overflow:ellipsis}.commit-info em{flex:0 0 auto;padding:2px 5px;border-radius:5px;font-size:7px;font-style:normal}.commit-info .preview-badge{color:var(--primary);background:var(--soft)}.commit-info .production-badge{color:#13765b;background:#e7f8f2}.commit-info p{display:flex;align-items:center;gap:5px;margin:5px 0 0;color:var(--muted);font-size:9px}.commit-info code{font-size:9px}.history-actions{display:flex;align-items:flex-start;gap:4px}.history-actions button{display:grid;place-items:center;width:25px;height:25px;padding:0;color:var(--muted);background:white;border:1px solid var(--border);border-radius:7px}.history-actions button:hover{color:var(--primary);background:var(--soft)} .history-list{flex:1 1 0;min-height:0;overflow:auto;padding:16px 16px 22px}.environment-versions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:22px}.environment-versions>button{min-width:0;padding:12px;text-align:left;background:#fafafd;border:1px solid var(--border);border-radius:12px}.environment-versions>button:hover,.environment-versions>button.active{border-color:#bca8ff;background:#faf8ff;box-shadow:0 5px 18px rgba(112,40,255,.08)}.environment-versions>button.production:hover,.environment-versions>button.production.active{border-color:#9bd8c6;background:#f4fcf9;box-shadow:0 5px 18px rgba(32,180,134,.08)}.environment-versions span{display:flex;align-items:center;gap:5px;color:var(--text-2);font-size:9px;font-weight:750}.environment-versions span>em{margin-left:auto;padding:2px 4px;color:var(--primary);background:var(--soft);border-radius:4px;font-size:7px;font-style:normal}.environment-versions .production span>em{color:#13765b;background:#e7f8f2}.environment-versions strong,.environment-versions small{display:block}.environment-versions strong{margin-top:13px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;letter-spacing:.02em}.environment-versions small{margin-top:5px;overflow:hidden;color:var(--muted);font-size:8px;white-space:nowrap;text-overflow:ellipsis}.history-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px}.history-heading h3{margin:0 0 4px;font-size:14px}.history-heading p{margin:0;color:var(--muted);font-size:9px}.history-heading>span{display:grid;place-items:center;width:25px;height:25px;color:var(--primary);background:var(--soft);border-radius:8px;font-size:10px;font-weight:800}.history-list article{display:grid;grid-template-columns:24px minmax(0,1fr) auto;gap:7px;min-height:69px}.history-list article.environment-version{margin:0 -5px;padding:0 5px;background:#fbfaff;border-radius:10px}.timeline{display:flex;align-items:center;flex-direction:column}.timeline i{display:grid;place-items:center;width:17px;height:17px;color:white;background:#d6d7df;border:3px solid white;box-shadow:0 0 0 1px #d6d7df;border-radius:50%;font-style:normal}.history-list article.environment-version .timeline i{background:var(--primary);box-shadow:0 0 0 1px var(--primary)}.timeline span{width:1px;flex:1;background:var(--divider)}.commit-info{min-width:0;padding-bottom:18px}.commit-info>div{display:flex;align-items:center;gap:5px;min-width:0}.commit-info strong{min-width:0;overflow:hidden;font-size:10px;line-height:1.4;white-space:nowrap;text-overflow:ellipsis}.commit-info em{flex:0 0 auto;padding:2px 5px;border-radius:5px;font-size:7px;font-style:normal}.commit-info .preview-badge{color:var(--primary);background:var(--soft)}.commit-info .production-badge{color:#13765b;background:#e7f8f2}.commit-info p{display:flex;align-items:center;gap:5px;margin:5px 0 0;color:var(--muted);font-size:9px}.commit-info code{font-size:9px}.history-actions{display:flex;align-items:flex-start;gap:4px}.history-actions button{display:grid;place-items:center;width:25px;height:25px;padding:0;color:var(--muted);background:white;border:1px solid var(--border);border-radius:7px}.history-actions button:hover{color:var(--primary);background:var(--soft)}
.preview-shell{min-height:0;min-width:0;overflow:hidden;display:flex;flex-direction:column}.preview-toolbar{min-height:66px;display:grid;grid-template-columns:minmax(190px,1fr) auto minmax(310px,1fr);grid-template-areas:"title modes actions";align-items:center;gap:12px;padding:0 18px;background:#fff;border-bottom:1px solid var(--border)}.preview-title{grid-area:title;display:flex;align-items:center;gap:10px;min-width:0}.preview-title>div{display:flex;align-items:center;gap:8px;min-width:0}.preview-title strong{overflow:hidden;font-size:12px;white-space:nowrap;text-overflow:ellipsis}.preview-title em{flex:0 0 auto;padding:3px 6px;color:#7957d5;background:#f2edff;border-radius:5px;font-size:8px;font-style:normal;font-weight:750}.preview-title em.production{color:#13765b;background:#e7f8f2}.status-dot{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:#7957d5;box-shadow:0 0 0 4px rgba(112,40,255,.1)}.status-dot.production{background:#20b486;box-shadow:0 0 0 4px rgba(32,180,134,.1)}.build-status{flex:0 0 auto;display:flex;align-items:center;gap:4px;padding:5px 7px;color:#188866;background:#ebfbf5;border-radius:7px;font-size:8px;font-weight:750}.build-status.failed{color:#b42318;background:#fff0ee}.build-status.unpublished{color:#986600;background:#fff8e5}.preview-modes{grid-area:modes;display:flex;align-items:center;gap:7px}.environment-toggle,.device-toggle{display:flex;padding:3px;background:#f2f2f6;border-radius:8px}.environment-toggle button{height:27px;display:flex;align-items:center;gap:5px;padding:0 9px;color:var(--muted);background:transparent;border:0;border-radius:6px;font-size:8px;font-weight:750}.environment-toggle button.active,.device-toggle button.active{color:var(--text);background:#fff;box-shadow:0 2px 7px rgba(36,16,95,.1)}.device-toggle button{display:grid;place-items:center;width:31px;height:27px;color:var(--muted);background:transparent;border:0;border-radius:6px}.toolbar-actions{grid-area:actions;display:flex;justify-content:flex-end;gap:6px;min-width:0}.toolbar-actions button,.toolbar-actions a{height:31px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 9px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:8px;font-size:8px;font-weight:750;white-space:nowrap}.toolbar-actions button:hover:not(:disabled),.toolbar-actions a:hover{color:var(--primary);background:var(--soft)}.toolbar-actions .environment-link.production-link{color:#13765b}.toolbar-actions .environment-link:disabled{color:#a8a9b0;background:#f5f5f7}.toolbar-actions .rebuild-button{color:var(--primary);border-color:#d8ccff;background:#faf8ff}.toolbar-actions .publish-button{color:white;background:var(--gradient);border:0}.toolbar-actions .publish-button:hover:not(:disabled){color:white;background:var(--gradient);box-shadow:0 7px 17px rgba(112,40,255,.2)}.toolbar-actions .publish-button:disabled{color:#9a9da8;background:#efeff4;border:1px solid #e1e1e7;box-shadow:none} .preview-shell{min-height:0;min-width:0;overflow:hidden;display:flex;flex-direction:column}.preview-toolbar{min-height:66px;display:grid;grid-template-columns:minmax(190px,1fr) auto minmax(310px,1fr);grid-template-areas:"title modes actions";align-items:center;gap:12px;padding:0 18px;background:#fff;border-bottom:1px solid var(--border)}.preview-title{grid-area:title;display:flex;align-items:center;gap:10px;min-width:0}.preview-title>div{display:flex;align-items:center;gap:8px;min-width:0}.preview-title strong{overflow:hidden;font-size:12px;white-space:nowrap;text-overflow:ellipsis}.preview-title em{flex:0 0 auto;padding:3px 6px;color:#7957d5;background:#f2edff;border-radius:5px;font-size:8px;font-style:normal;font-weight:750}.preview-title em.production{color:#13765b;background:#e7f8f2}.status-dot{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:#7957d5;box-shadow:0 0 0 4px rgba(112,40,255,.1)}.status-dot.production{background:#20b486;box-shadow:0 0 0 4px rgba(32,180,134,.1)}.build-status{flex:0 0 auto;display:flex;align-items:center;gap:4px;padding:5px 7px;color:#188866;background:#ebfbf5;border-radius:7px;font-size:8px;font-weight:750}.build-status.failed{color:#b42318;background:#fff0ee}.build-status.unpublished{color:#986600;background:#fff8e5}.preview-modes{grid-area:modes;display:flex;align-items:center;gap:7px}.environment-toggle,.device-toggle{display:flex;padding:3px;background:#f2f2f6;border-radius:8px}.environment-toggle button{height:27px;display:flex;align-items:center;gap:5px;padding:0 9px;color:var(--muted);background:transparent;border:0;border-radius:6px;font-size:8px;font-weight:750}.environment-toggle button.active,.device-toggle button.active{color:var(--text);background:#fff;box-shadow:0 2px 7px rgba(36,16,95,.1)}.device-toggle button{display:grid;place-items:center;width:31px;height:27px;color:var(--muted);background:transparent;border:0;border-radius:6px}.toolbar-actions{grid-area:actions;display:flex;justify-content:flex-end;gap:6px;min-width:0}.toolbar-actions button,.toolbar-actions a{height:31px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 9px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:8px;font-size:8px;font-weight:750;white-space:nowrap}.toolbar-actions button:hover:not(:disabled),.toolbar-actions a:hover{color:var(--primary);background:var(--soft)}.toolbar-actions .environment-link.production-link{color:#13765b}.toolbar-actions .environment-link:disabled{color:#a8a9b0;background:#f5f5f7}.toolbar-actions .rebuild-button{color:var(--primary);border-color:#d8ccff;background:#faf8ff}.toolbar-actions .undo-button{color:#9a6100;border-color:#ecd8ad;background:#fffaf0}.toolbar-actions .publish-button{color:white;background:var(--gradient);border:0}.toolbar-actions .publish-button:hover:not(:disabled){color:white;background:var(--gradient);box-shadow:0 7px 17px rgba(112,40,255,.2)}.toolbar-actions .publish-button:disabled{color:#9a9da8;background:#efeff4;border:1px solid #e1e1e7;box-shadow:none}
.history-edit-lock{flex:0 0 auto;margin:0 14px 10px;padding:9px 11px;color:#6f4b00;background:#fff8e7;border:1px solid #f0dfb8;border-radius:10px;font-size:9px;line-height:1.5}.history-edit-lock>div{display:flex;align-items:flex-start;gap:7px}.history-edit-lock svg{flex:0 0 auto;margin-top:1px}.history-panel-lock{margin:0 0 14px}.history-list article{display:block;min-height:0;margin-bottom:5px}.history-list article.selected{background:#faf8ff;border-radius:11px}.history-row{width:100%;min-width:0;display:grid;grid-template-columns:24px minmax(0,1fr) auto;gap:8px;padding:9px 8px 5px;color:var(--text);text-align:left;background:transparent;border:1px solid transparent;border-radius:11px}.history-list article.selected .history-row{border-color:#ded4ff}.history-row .timeline{min-height:49px}.history-list article.selected .timeline i{background:var(--primary);box-shadow:0 0 0 1px var(--primary)}.history-row .commit-info{display:block;min-width:0;padding:0 0 10px}.commit-info>span{display:flex;align-items:center;gap:5px;min-width:0}.commit-info>span>strong{min-width:0;overflow:hidden;font-size:10px;line-height:1.4;white-space:nowrap;text-overflow:ellipsis}.commit-info em{flex:0 0 auto;padding:2px 5px;border-radius:5px;font-size:7px;font-style:normal}.commit-info .current-badge{color:#775400;background:#fff2c7}.commit-info>small{display:flex;align-items:center;gap:5px;margin-top:6px;color:var(--muted);font-size:8px}.commit-info>small code{font-size:8px}.history-row-state,.history-preview-button{align-self:start;min-height:24px;padding:4px 7px;border-radius:6px;font-size:7px;font-weight:750}.history-row-state{color:var(--primary);background:var(--soft)}.history-preview-button{display:flex;align-items:center;gap:4px;color:var(--text-2);background:#fff;border:1px solid var(--border)}.history-preview-button:hover:not(:disabled){color:var(--primary);background:var(--soft);border-color:#d8ccff}
.version-state-strip{flex:0 0 70px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#fff;border-bottom:1px solid var(--border)}.version-state-strip article{min-width:0;padding:10px 16px;border-right:1px solid var(--divider)}.version-state-strip article:last-child{border-right:0}.version-state-strip article>span,.version-state-strip strong,.version-state-strip small{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.version-state-strip article>span{color:var(--muted);font-size:8px;font-weight:750}.version-state-strip article>span em{margin-left:4px;padding:2px 5px;color:#6a4bb8;background:#f2edff;border-radius:5px;font-size:7px;font-style:normal}.version-state-strip strong{margin-top:5px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px}.version-state-strip small{margin-top:3px;color:var(--muted);font-size:7px}.version-state-strip article.error{background:#fffafa}.version-state-strip article.error>span em{color:#b42318;background:#fff0ee}.version-state-strip article.production{background:#fbfffd}.version-state-strip article.production strong{color:#13765b}
.browser-stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:25px;background:#eff0f5;overflow:auto}.browser-frame{position:relative;width:100%;height:100%;overflow:hidden;background:#fff;border:1px solid #dfe0e7;border-radius:11px;box-shadow:0 15px 45px rgba(30,24,53,.1);transition:.35s ease}.browser-frame.mobile{width:390px;max-height:760px}.browser-chrome{height:31px;display:grid;grid-template-columns:70px 1fr 70px;align-items:center;padding:0 10px;background:#fafafd;border-bottom:1px solid #ebeaf0}.browser-chrome>div{display:flex;gap:5px}.browser-chrome i{width:7px;height:7px;border-radius:50%;background:#e1e1e7}.browser-chrome i:first-child{background:#ff8b86}.browser-chrome i:nth-child(2){background:#ffd36b}.browser-chrome i:nth-child(3){background:#71d19a}.browser-chrome span{justify-self:center;width:min(390px,80%);padding:4px 10px;text-align:center;color:#9a9ca6;background:#f0f0f4;border-radius:5px;font-size:8px}.browser-chrome>svg{justify-self:end;color:#a7a9b3}.browser-frame iframe{display:block;width:100%;height:calc(100% - 31px);border:0;background:#fff}.production-empty{height:calc(100% - 31px);display:flex;align-items:center;justify-content:center;flex-direction:column;padding:30px;text-align:center;background:linear-gradient(180deg,#fbfffd,#f7faf9)}.production-empty>span{display:grid;place-items:center;width:52px;height:52px;color:#168064;background:#e8f8f2;border-radius:16px}.production-empty strong{margin-top:17px;font-size:15px}.production-empty p{max-width:360px;margin:8px 0 20px;color:var(--muted);font-size:10px;line-height:1.7}.production-empty button{height:36px;display:flex;align-items:center;gap:7px;padding:0 14px;color:white;background:linear-gradient(135deg,#15936e,#20b486);border:0;border-radius:9px;font-size:10px;font-weight:800}.preview-busy{position:absolute;inset:31px 0 0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--primary);background:rgba(248,248,252,.72);backdrop-filter:blur(4px)}.preview-busy strong{color:var(--text);font-size:13px}.preview-busy span{color:var(--muted);font-size:10px}.preview-footer{height:29px;padding:0 22px;display:flex;align-items:center;justify-content:space-between;color:var(--muted);background:#fff;border-top:1px solid var(--divider);font-size:8px}.preview-footer span{display:flex;align-items:center;gap:5px} .browser-stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:25px;background:#eff0f5;overflow:auto}.browser-frame{position:relative;width:100%;height:100%;overflow:hidden;background:#fff;border:1px solid #dfe0e7;border-radius:11px;box-shadow:0 15px 45px rgba(30,24,53,.1);transition:.35s ease}.browser-frame.mobile{width:390px;max-height:760px}.browser-chrome{height:31px;display:grid;grid-template-columns:70px 1fr 70px;align-items:center;padding:0 10px;background:#fafafd;border-bottom:1px solid #ebeaf0}.browser-chrome>div{display:flex;gap:5px}.browser-chrome i{width:7px;height:7px;border-radius:50%;background:#e1e1e7}.browser-chrome i:first-child{background:#ff8b86}.browser-chrome i:nth-child(2){background:#ffd36b}.browser-chrome i:nth-child(3){background:#71d19a}.browser-chrome span{justify-self:center;width:min(390px,80%);padding:4px 10px;text-align:center;color:#9a9ca6;background:#f0f0f4;border-radius:5px;font-size:8px}.browser-chrome>svg{justify-self:end;color:#a7a9b3}.browser-frame iframe{display:block;width:100%;height:calc(100% - 31px);border:0;background:#fff}.production-empty{height:calc(100% - 31px);display:flex;align-items:center;justify-content:center;flex-direction:column;padding:30px;text-align:center;background:linear-gradient(180deg,#fbfffd,#f7faf9)}.production-empty>span{display:grid;place-items:center;width:52px;height:52px;color:#168064;background:#e8f8f2;border-radius:16px}.production-empty strong{margin-top:17px;font-size:15px}.production-empty p{max-width:360px;margin:8px 0 20px;color:var(--muted);font-size:10px;line-height:1.7}.production-empty button{height:36px;display:flex;align-items:center;gap:7px;padding:0 14px;color:white;background:linear-gradient(135deg,#15936e,#20b486);border:0;border-radius:9px;font-size:10px;font-weight:800}.preview-busy{position:absolute;inset:31px 0 0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--primary);background:rgba(248,248,252,.72);backdrop-filter:blur(4px)}.preview-busy strong{color:var(--text);font-size:13px}.preview-busy span{color:var(--muted);font-size:10px}.preview-footer{height:29px;padding:0 22px;display:flex;align-items:center;justify-content:space-between;color:var(--muted);background:#fff;border-top:1px solid var(--divider);font-size:8px}.preview-footer span{display:flex;align-items:center;gap:5px}
@media(max-width:1100px){.create-layout{grid-template-columns:1fr;gap:48px;padding-top:40px}.create-intro{text-align:center}.create-intro>p,.create-intro h1{margin-left:auto;margin-right:auto}.intro-tag{margin:auto}.feature-row{max-width:620px;margin-left:auto;margin-right:auto}.create-card-wrap{justify-content:center}.workspace{grid-template-columns:58px 340px 1fr}} @media(max-width:1100px){.create-layout{grid-template-columns:1fr;gap:48px;padding-top:40px}.create-intro{text-align:center}.create-intro>p,.create-intro h1{margin-left:auto;margin-right:auto}.intro-tag{margin:auto}.feature-row{max-width:620px;margin-left:auto;margin-right:auto}.create-card-wrap{justify-content:center}.workspace{grid-template-columns:58px 340px 1fr}}
@media(max-width:1250px) and (min-width:801px){.preview-toolbar{height:96px;grid-template-columns:minmax(160px,1fr) auto;grid-template-rows:42px 42px;grid-template-areas:"title actions" "modes modes";gap:0 10px;padding:6px 12px}.preview-modes{justify-content:center}.toolbar-actions .environment-link{width:31px;padding:0}.toolbar-actions .environment-link>span{display:none}} @media(max-width:1250px) and (min-width:801px){.preview-toolbar{height:96px;grid-template-columns:minmax(160px,1fr) auto;grid-template-rows:42px 42px;grid-template-areas:"title actions" "modes modes";gap:0 10px;padding:6px 12px}.preview-modes{justify-content:center}.toolbar-actions .environment-link{width:31px;padding:0}.toolbar-actions .environment-link>span{display:none}}
......
...@@ -22,6 +22,8 @@ export interface SiteInfo { ...@@ -22,6 +22,8 @@ export interface SiteInfo {
previewUrl: string; previewUrl: string;
productionUrl?: string; productionUrl?: string;
currentCommit: string; currentCommit: string;
previewCommit?: string;
previousCommit?: string;
publishedCommit?: string; publishedCommit?: string;
publishedAt?: string; publishedAt?: string;
publishStatus: PublishStatus; publishStatus: PublishStatus;
...@@ -61,3 +63,8 @@ export interface PublishResult { ...@@ -61,3 +63,8 @@ export interface PublishResult {
commit: string; commit: string;
publishedAt: string; publishedAt: string;
} }
export interface PreviewVersionResult {
previewUrl: string;
commit: string;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment