Compare commits

...

8 Commits

Author SHA1 Message Date
stringadmin 45fe601e17 Merge pull request 'Codex/time driven progress' (#34) from codex/time-driven-progress into master
Web Quality / verify (push) Has been cancelled
Reviewed-on: #34
2026-06-10 10:05:08 +00:00
stringadmin 9d9c3ce186 Merge branch 'master' into codex/time-driven-progress
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 10:04:59 +00:00
stringadmin 228e89cfb6 Merge pull request 'feat: 多页面UI打磨 — 设置面板、状态反馈与样式升级' (#35) from feat/ui-polish-and-skills into master
Web Quality / verify (push) Has been cancelled
Reviewed-on: #35
2026-06-10 09:57:06 +00:00
stringadmin 0fbb5372d5 Merge branch 'master' into feat/ui-polish-and-skills
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 09:56:47 +00:00
ludan a6626beb32 feat: 多页面UI打磨 — 设置面板、状态反馈与样式升级
Web Quality / verify (pull_request) Has been cancelled
本次更新对多个功能页面进行了系统性的 UI/UX 打磨,统一了交互模式并补充了缺失的状态反馈。

## 新增功能
- WorkbenchPage: 图片提示词案例区域新增加载骨架屏、错误回退、空数据三种状态展示
- CharacterMixPage: 新增左侧设置面板(驱动提示词、图像检测开关、水印开关),支持清除已上传的人物图/参考视频
- DigitalHumanPage: 新增左侧设置面板(提示词输入、去水印/保留原声开关),支持清除已上传的人像/音频,增加取消生成按钮
- ImageWorkbenchPage / ResolutionUpscalePage: 新增参数设置面板和资产清除交互
- MorePage: 新增页面入口

## UI 优化
- 统一 Toggle 开关组件: 所有设置页面采用一致的 .studio-toggle 交互模式
- 资产清除: 各上传区域新增清除按钮,含二次确认和提示反馈
- 生成按钮: 统一为带图标的 .studio-generate-btn,增加 disabled/loading 状态
- ConversationSidebar / ProjectSidebar: 侧边栏交互细节优化

## 样式升级
- image-workbench.css: 大幅扩展样式 (+1900 行),覆盖设置面板、上传区、结果展示等
- workbench.css: 新增 666 行样式,含骨架屏动画、案例卡片网格、状态占位等
- subtitle-removal.css: 补充设置面板样式
2026-06-10 17:54:45 +08:00
stringadmin aa5ba96764 Merge branch 'master' into codex/time-driven-progress
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 09:51:55 +00:00
stringadmin ba2e7cfda2 Consolidate generation task stores
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 17:37:18 +08:00
stringadmin e9601a651c Use time-driven generation progress
Web Quality / verify (push) Has been cancelled
2026-06-10 16:00:26 +08:00
29 changed files with 3545 additions and 317 deletions
+9
View File
@@ -0,0 +1,9 @@
# Optimization Backlog
## Progress Contract Frontend Consumption
- Status: pending
- Priority: medium
- Context: The backend now returns `progressSource`, `stage`, `startedAt`, and `expectedDurationMs` on generation task status payloads. The frontend progress UI currently still derives these values locally from message state and static defaults.
- Follow-up: Wire the backend task progress contract through `aiGenerationClient`, task/message view models, and the progress card components so model-aware `expectedDurationMs` and real provider progress can be consumed end to end.
- Boundary: Keep this separate from the task store consolidation. The store consolidation is complete without requiring these fields because `WebGenerationPreviewTask` is not the source for Workbench progress cards.
+18 -1
View File
@@ -150,6 +150,10 @@ export interface AiTaskStatus {
type: "image" | "video";
status: "pending" | "running" | "completed" | "failed" | "cancelled";
progress: number;
progressSource?: "real" | "estimated" | string | null;
stage?: string | null;
startedAt?: string | null;
expectedDurationMs?: number | null;
resultUrl: string | null;
error: string | null;
params?: Record<string, unknown>;
@@ -514,7 +518,20 @@ export const aiGenerationClient = {
subscribeTaskStatus(
taskId: string,
onUpdate: (task: Pick<AiTaskStatus, "taskId" | "status" | "progress" | "resultUrl" | "error">) => void,
onUpdate: (
task: Pick<
AiTaskStatus,
| "taskId"
| "status"
| "progress"
| "progressSource"
| "stage"
| "startedAt"
| "expectedDurationMs"
| "resultUrl"
| "error"
>,
) => void,
): () => void {
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
const controller = new AbortController();
+19 -3
View File
@@ -9,6 +9,10 @@ export interface TaskProgressEvent {
taskId: string;
status: string;
progress: number;
progressSource?: "real" | "estimated" | string | null;
stage?: string | null;
startedAt?: string | null;
expectedDurationMs?: number | null;
resultUrl?: string | null;
error?: string | null;
}
@@ -37,7 +41,8 @@ export function waitForTask(
operation: options.operation,
});
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const noProgressTimeoutMs =
options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const startedAt = options.startedAt ?? Date.now();
return new Promise((resolve, reject) => {
@@ -58,7 +63,10 @@ export function waitForTask(
};
timeoutId = setTimeout(
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
() =>
settle(() =>
reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))),
),
timeoutMs,
);
@@ -105,7 +113,11 @@ export function waitForTask(
policy: { ...timeoutPolicy, noProgressTimeoutMs },
});
if (timeoutReason) {
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
settle(() =>
reject(
new Error(buildLocalTimeoutMessage(options.kind || "video")),
),
);
return;
}
try {
@@ -114,6 +126,10 @@ export function waitForTask(
taskId,
status: task.status,
progress: task.progress || 0,
progressSource: task.progressSource,
stage: task.stage,
startedAt: task.startedAt,
expectedDurationMs: task.expectedDurationMs,
resultUrl: task.resultUrl,
error: task.error,
});
+6 -1
View File
@@ -8,6 +8,7 @@ interface BeforeAfterCompareProps {
sourceAlt?: string;
resultAlt?: string;
className?: string;
aspectRatio?: string;
onSourceLoad?: (width: number, height: number) => void;
}
@@ -26,6 +27,7 @@ export default function BeforeAfterCompare({
sourceAlt = "原图",
resultAlt = "结果",
className = "",
aspectRatio,
onSourceLoad,
}: BeforeAfterCompareProps) {
const stageRef = useRef<HTMLDivElement>(null);
@@ -43,7 +45,10 @@ export default function BeforeAfterCompare({
<div
ref={stageRef}
className={`before-after-compare ${className}`}
style={{ "--compare-position": `${position}%` } as CSSProperties}
style={{
"--compare-position": `${position}%`,
...(aspectRatio ? { "--compare-aspect-ratio": aspectRatio } : {}),
} as CSSProperties}
aria-label="前后对比"
>
<div className="before-after-compare__layer before-after-compare__layer--source">
+6
View File
@@ -33,6 +33,10 @@ import { communityClient } from "../../api/communityClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import {
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
} from "../../hooks/useSmoothedProgress";
import type { WebCanvasWorkflow } from "../../types";
import type { AssetLibraryCategory } from "../assets/localAssetStore";
import {
@@ -4492,6 +4496,7 @@ function CanvasPage({
progress={imageNodeProgress}
status={imageTaskState?.status || "running"}
message={imageTaskState?.message || "图片生成中"}
expectedDurationMs={DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS}
/>
) : null}
{imageNodeFocusActive && imageFocusSelectionReady ? (
@@ -4866,6 +4871,7 @@ function CanvasPage({
progress={videoNodeProgress}
status={videoTaskState?.status || "running"}
message={videoTaskState?.message || "视频生成中"}
expectedDurationMs={DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS}
/>
) : null}
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
@@ -1,4 +1,8 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import {
DEFAULT_GENERATION_EXPECTED_DURATION_MS,
useSmoothedProgress,
type ProgressSource,
} from "../../hooks/useSmoothedProgress";
import { canvasGenerationProgressStyle } from "./canvasUtils";
type NodeGenStatus = "submitting" | "running" | "success" | "error";
@@ -7,10 +11,24 @@ interface CanvasSmoothedProgressRingProps {
progress: number;
status: NodeGenStatus;
message?: string;
progressSource?: ProgressSource;
startedAt?: number | string | Date | null;
expectedDurationMs?: number | null;
}
export function CanvasSmoothedProgressRing({ progress, status, message }: CanvasSmoothedProgressRingProps) {
const smoothed = useSmoothedProgress(progress, status);
export function CanvasSmoothedProgressRing({
progress,
status,
message,
progressSource = "estimated",
startedAt,
expectedDurationMs = DEFAULT_GENERATION_EXPECTED_DURATION_MS,
}: CanvasSmoothedProgressRingProps) {
const smoothed = useSmoothedProgress(progress, status, {
progressSource,
startedAt,
expectedDurationMs,
});
return (
<div
className="studio-canvas-node-generation-progress"
@@ -18,7 +36,10 @@ export function CanvasSmoothedProgressRing({ progress, status, message }: Canvas
aria-live="polite"
style={canvasGenerationProgressStyle(smoothed)}
>
<span className="studio-canvas-node-generation-progress__ring" aria-hidden="true" />
<span
className="studio-canvas-node-generation-progress__ring"
aria-hidden="true"
/>
<strong>{message}</strong>
<em>{smoothed}%</em>
</div>
+22 -2
View File
@@ -262,7 +262,17 @@ export async function waitForImageTaskResult(
abortRef,
onProgress: (e) => {
if (onStatus) {
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
onStatus({
taskId,
status: e.status,
progress: e.progress,
progressSource: e.progressSource,
stage: e.stage,
startedAt: e.startedAt,
expectedDurationMs: e.expectedDurationMs,
resultUrl: e.resultUrl ?? undefined,
error: e.error ?? undefined,
} as AiTaskStatus);
}
},
});
@@ -281,7 +291,17 @@ export async function waitForVideoTaskResult(
abortRef,
onProgress: (e) => {
if (onStatus) {
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
onStatus({
taskId,
status: e.status,
progress: e.progress,
progressSource: e.progressSource,
stage: e.stage,
startedAt: e.startedAt,
expectedDurationMs: e.expectedDurationMs,
resultUrl: e.resultUrl ?? undefined,
error: e.error ?? undefined,
} as AiTaskStatus);
}
},
});
+119 -74
View File
@@ -281,6 +281,94 @@ function CharacterMixPage({
}
};
const clearCharacterAsset = () => {
if (characterPreview) URL.revokeObjectURL(characterPreview);
setCharacterFile("");
setCharacterPreview("");
setCharacterDataUrl("");
setFaceHint(null);
if (characterInputRef.current) characterInputRef.current.value = "";
setNotice("已移除人物图");
};
const clearReferenceVideo = () => {
if (videoPreview) URL.revokeObjectURL(videoPreview);
setVideoFile("");
setVideoPreview("");
setVideoDataUrl("");
if (videoInputRef.current) videoInputRef.current.value = "";
setNotice("已移除参考视频");
};
const characterMixSettingsPanel = (
<div className="studio-panel__section character-mix-settings-panel">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div className="character-mix-prompt-field">
<div className="studio-label"></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="保持角色原有服装,动作流畅自然"
rows={3}
maxLength={1000}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${checkImage ? " is-on" : ""}`} onClick={() => setCheckImage(!checkImage)}>
<span className="studio-toggle__thumb" />
</button>
</div>
{checkImage && characterPreview && faceHint && (
<div className={`character-mix-face-hint character-mix-face-hint--${faceHint}`}>
{faceHint === "analyzing" ? (
<>
<InfoCircleOutlined />
<span>...</span>
</>
) : (
<>
<CheckCircleOutlined />
<span></span>
</>
)}
</div>
)}
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !characterDataUrl || !videoDataUrl}>
{isCreating ? <LoadingOutlined /> : <PlayCircleOutlined />}
{isCreating ? "生成中..." : "开始迁移"}
</button>
{resultUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
);
return (
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
<header className="image-workbench-topbar">
@@ -339,8 +427,10 @@ function CharacterMixPage({
<StudioToolLayout
noTop
noRight
leftPanel={
<div
className="character-mix-source-panel"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -389,6 +479,20 @@ function CharacterMixPage({
<strong>{characterFile || "上传人物图"}</strong>
<small></small>
</span>
{characterPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除人物图"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearCharacterAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
</div>
</div>
@@ -427,9 +531,24 @@ function CharacterMixPage({
<strong>{videoFile || "上传参考视频"}</strong>
<small>MP4 / MOV / AVI</small>
</span>
{videoPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除参考视频"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearReferenceVideo();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
</div>
</div>
{characterMixSettingsPanel}
</div>
}
canvas={
@@ -480,80 +599,6 @@ function CharacterMixPage({
</div>
)
}
rightPanel={
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div style={{ marginBottom: 10 }}>
<div className="studio-label" style={{ fontSize: 11, color: "var(--fg-muted, #999)", marginBottom: 4 }}></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="保持角色原有服装,动作流畅自然"
rows={3}
maxLength={1000}
style={{
width: "100%", resize: "vertical", background: "var(--bg-elevated, #1a1a1a)",
border: "1px solid var(--border-subtle, #333)", borderRadius: 6,
padding: "6px 8px", fontSize: 12, color: "var(--fg-body, #eee)",
fontFamily: "inherit", outline: "none", boxSizing: "border-box",
}}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${checkImage ? " is-on" : ""}`} onClick={() => setCheckImage(!checkImage)}>
<span className="studio-toggle__thumb" />
</button>
</div>
{checkImage && characterPreview && faceHint && (
<div className={`character-mix-face-hint character-mix-face-hint--${faceHint}`}>
{faceHint === "analyzing" ? (
<>
<InfoCircleOutlined />
<span>...</span>
</>
) : (
<>
<CheckCircleOutlined />
<span></span>
</>
)}
</div>
)}
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !characterDataUrl || !videoDataUrl}>
{isCreating ? <LoadingOutlined /> : <PlayCircleOutlined />}
{isCreating ? "生成中..." : "开始迁移"}
</button>
{resultUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
}
statusBar={
<>
<span className="studio-status-bar__badge studio-status-bar__badge--idle"></span>
+120 -77
View File
@@ -206,6 +206,24 @@ function DigitalHumanPage({
}
};
const clearImageAsset = () => {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImageName("");
setImageFile(null);
setImagePreview("");
if (imageInputRef.current) imageInputRef.current.value = "";
setNotice("已移除参考人像");
};
const clearAudioAsset = () => {
if (audioPreview) URL.revokeObjectURL(audioPreview);
setAudioName("");
setAudioFile(null);
setAudioPreview("");
if (audioInputRef.current) audioInputRef.current.value = "";
setNotice("已移除音频源");
};
const handleDownloadResult = async () => {
if (!resultVideoUrl || isDownloadingResult) return;
setIsDownloadingResult(true);
@@ -418,6 +436,76 @@ function DigitalHumanPage({
}
};
const digitalHumanSettingsPanel = (
<div className="studio-panel__section digital-human-settings-panel">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div className="digital-human-prompt-field">
<div className="studio-label"></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="例如:自然微笑,边说边轻微点头"
rows={3}
maxLength={2500}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${!watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${keepOriginalAudio ? " is-on" : ""}`} onClick={() => setKeepOriginalAudio(!keepOriginalAudio)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
<PlayCircleOutlined />
{isCreating ? "生成中..." : "开始生成"}
</button>
{isCreating && (
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
<CloseCircleOutlined />
</button>
)}
{resultVideoUrl && (
<button type="button" className="studio-generate-btn" onClick={() => {
setResultVideoUrl("");
setActiveTaskId("");
setTaskProgress(0);
setNotice("已清空工作区");
}}>
</button>
)}
{resultVideoUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
);
return (
<section className="image-workbench-page digital-human-page" aria-label="数字人">
<header className="image-workbench-topbar">
@@ -476,8 +564,10 @@ function DigitalHumanPage({
<StudioToolLayout
noTop
noRight
leftPanel={
<div
className="digital-human-source-panel"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -523,6 +613,20 @@ function DigitalHumanPage({
<strong>{imageName || "上传参考图"}</strong>
<small>PNG / JPG / WEBP</small>
</span>
{imagePreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除参考人像"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearImageAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
</div>
</div>
@@ -558,10 +662,26 @@ function DigitalHumanPage({
<strong>{audioName || "上传音频"}</strong>
<small>MP3 / WAV / M4A 5 </small>
</span>
{audioPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除音频源"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearAudioAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
{audioPreview ? <audio src={audioPreview} controls className="studio-audio-preview" /> : null}
</div>
</div>
{digitalHumanSettingsPanel}
</div>
}
canvas={
@@ -596,83 +716,6 @@ function DigitalHumanPage({
</div>
)
}
rightPanel={
<>
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div style={{ marginBottom: 8 }}>
<div className="studio-label" style={{ fontSize: 11, color: "var(--fg-muted, #999)", marginBottom: 4 }}></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="例如:自然微笑,边说边轻微点头"
rows={3}
maxLength={2500}
style={{
width: "100%", resize: "vertical", background: "var(--bg-elevated, #1a1a1a)",
border: "1px solid var(--border-subtle, #333)", borderRadius: 6,
padding: "6px 8px", fontSize: 12, color: "var(--fg-body, #eee)",
fontFamily: "inherit", outline: "none", boxSizing: "border-box",
}}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${!watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${keepOriginalAudio ? " is-on" : ""}`} onClick={() => setKeepOriginalAudio(!keepOriginalAudio)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
<PlayCircleOutlined />
{isCreating ? "生成中..." : "开始生成"}
</button>
{isCreating && (
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
<CloseCircleOutlined />
</button>
)}
{resultVideoUrl && (
<button type="button" className="studio-generate-btn" onClick={() => {
setResultVideoUrl("");
setActiveTaskId("");
setTaskProgress(0);
setNotice("已清空工作区");
}}>
</button>
)}
{resultVideoUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
</>
}
statusBar={
<>
<span className="studio-status-bar__badge studio-status-bar__badge--running"></span>
@@ -1,4 +1,9 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import { useRef } from "react";
import {
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
formatEstimatedRemainingLabel,
useSmoothedProgress,
} from "../../hooks/useSmoothedProgress";
interface EcommerceProgressBarProps {
status: "idle" | "generating" | "done" | "failed" | string;
@@ -12,19 +17,42 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
return "running";
}
export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProps) {
const progress = mapStatus(status) === "running" ? 50 : 100;
const smoothed = useSmoothedProgress(progress, mapStatus(status));
export function EcommerceProgressBar({
status,
label,
}: EcommerceProgressBarProps) {
const startedAtRef = useRef(Date.now());
const mappedStatus = mapStatus(status);
const progress = mappedStatus === "running" ? 50 : 100;
const smoothed = useSmoothedProgress(progress, mappedStatus, {
progressSource: mappedStatus === "running" ? "estimated" : "real",
startedAt: startedAtRef.current,
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
});
const remainingLabel =
mappedStatus === "running"
? formatEstimatedRemainingLabel({
nowMs: Date.now(),
startedAt: startedAtRef.current,
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
})
: null;
if (status === "idle") return null;
return (
<div className="ecommerce-progress-bar">
<span className="ecommerce-progress-bar__label">{label || "AI 正在生成"}</span>
<span className="ecommerce-progress-bar__label">
{label || "AI 正在生成"}
{remainingLabel ? ` / ${remainingLabel}` : ""}
</span>
<div className="ecommerce-progress-bar__track">
<div className="ecommerce-progress-bar__fill" style={{ width: `${smoothed}%` }} />
<div
className="ecommerce-progress-bar__fill"
style={{ width: `${smoothed}%` }}
/>
</div>
<span className="ecommerce-progress-bar__value">{smoothed}%</span>
</div>
);
}
}
+3 -17
View File
@@ -13,13 +13,11 @@ import "../../styles/pages/home.css";
import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase";
import ModelGenerationShowcase from "./ModelGenerationShowcase";
const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
const {
ecommerce: featureEcommerceImage,
script: featureScriptImage,
token: featureTokenImage,
} = ossAssets.home.features;
interface HomePageProps {
@@ -42,16 +40,6 @@ const HOME_CAROUSEL_IMAGES = [
];
const HOME_FEATURES = [
{
key: "model",
eyebrow: "AI Generation",
title: "模型生成",
description: "通过AI模型生成文本、图片、视频,三种模式覆盖全内容类型,Agent对话式交互智能产出。",
imageUrl: featureTokenImage,
actionLabel: "开始生成",
icon: <ThunderboltOutlined />,
stats: ["文本生成", "图片生成", "视频生成"],
},
{
key: "ecommerce",
eyebrow: "AI Commerce",
@@ -646,7 +634,7 @@ function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcomm
<main className="omni-home__feature-pages" aria-label="OmniAI 功能介绍">
{HOME_FEATURES.map((feature, index) => (
<section key={feature.key} className={`omni-home__feature-page is-${feature.key}${index % 2 ? " is-alt" : ""}`}>
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
{feature.key !== "script" && feature.key !== "ecommerce" ? (
<div className="omni-home__feature-copy">
<span>
{feature.icon}
@@ -660,18 +648,16 @@ function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcomm
</button>
</div>
) : null}
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce"}>
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "ecommerce"}>
{feature.key === "script" ? (
<ScriptReviewShowcase />
) : feature.key === "model" ? (
<ModelGenerationShowcase />
) : feature.key === "ecommerce" ? (
<EcommerceFeatureShowcase />
) : (
<img src={feature.imageUrl} alt="" />
)}
</div>
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
{feature.key !== "script" && feature.key !== "ecommerce" ? (
<div className="omni-home__feature-stats" aria-hidden="true">
{feature.stats.map((item) => (
<span key={item}>{item}</span>
@@ -609,6 +609,20 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
);
const handleRemoveWorkbenchResult = (index: number) => {
setResultImages((current) => {
const next = current.filter((_, imageIndex) => imageIndex !== index);
if (next.length) {
saveToolTaskState("imagewb", { taskId: taskIdRef.current || "", resultUrl: next[0], status: "完成", progress: 100 });
setStatus(`已移除生成图,剩余 ${next.length}`);
} else {
clearToolTaskState("imagewb");
setStatus("已移除生成图");
}
return next;
});
};
const handleGenerate = async () => {
if (!referenceImages.length && !prompt.trim()) {
setStatus("请先上传参考图或输入提示词");
@@ -797,7 +811,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label="删除局部重绘素材"
onClick={handleRemoveInpaintImage}
>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -830,7 +844,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</label>
</section>
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-inpaint-params-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
@@ -842,7 +856,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
</section>
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-inpaint-prompt-card">
<h3></h3>
<textarea
className="image-workbench-prompt"
@@ -967,7 +981,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
) : activeTool === "camera" ? (
<main className="image-workbench-layout image-workbench-layout--camera">
<aside className="image-workbench-panel image-workbench-panel--left">
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-camera-material">
<div className="image-workbench-section-title">
<h3></h3>
<span>{cameraImage ? "已导入" : "待上传"}</span>
@@ -997,7 +1011,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label="删除镜头参考图"
onClick={handleRemoveCameraImage}
>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -1248,7 +1262,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label={`删除参考图 ${index + 1}`}
onClick={() => handleRemoveReferenceImage(index)}
>
×
<DeleteOutlined />
</button>
</div>
))}
@@ -1282,7 +1296,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label="删除参考图"
onClick={() => handleRemoveReferenceImage(0)}
>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -1300,7 +1314,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
</section>
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-output-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
@@ -1365,6 +1379,14 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<div className="image-workbench-result-grid">
{resultImages.map((url, i) => (
<div key={url} className="image-workbench-result-card">
<button
type="button"
className="image-workbench-result-remove"
aria-label={`移除生成结果 ${i + 1}`}
onClick={() => handleRemoveWorkbenchResult(i)}
>
<DeleteOutlined />
</button>
<a href={url} target="_blank" rel="noopener noreferrer" className="image-workbench-result-item">
<img src={url} alt={`生成结果 ${i + 1}`} />
</a>
+2
View File
@@ -40,12 +40,14 @@ interface MoreTool {
}
const toolPreviewImages: Record<string, string> = {
workbench: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/image-workbench-20260609132455.png",
inpaint: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%B1%80%E9%83%A8%E9%87%8D%E7%BB%98.PNG",
camera: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E9%95%9C%E5%A4%B4%E5%AE%9E%E9%AA%8C%E5%AE%A4.PNG",
upscale: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%88%86%E8%BE%A8%E7%8E%87%E6%8F%90%E5%8D%87.PNG",
watermarkRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%8E%BB%E6%B0%B4%E5%8D%B0.PNG",
dialogGenerator: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E4%BA%A4%E4%BA%92%E5%BC%8F%E5%AF%B9%E8%AF%9D%E6%A1%86%E7%94%9F%E6%88%90%E5%99%A8.PNG",
subtitleRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%AD%97%E5%B9%95%E5%8E%BB%E9%99%A4.PNG",
digitalHuman: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/digital-human-20260609132455.png",
characterMix: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E8%A7%92%E8%89%B2%E8%BF%81%E7%A7%BB.PNG",
avatarConsole: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E6%95%B0%E5%AD%97%E4%BA%BA%E6%8E%A7%E5%88%B6%E5%8F%B0.PNG",
};
@@ -439,7 +439,7 @@ function ResolutionUpscalePage({
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -459,13 +459,20 @@ function ResolutionUpscalePage({
<section className="image-workbench-control-card">
<h3></h3>
{mode === "image" ? (
<label className="image-workbench-select">
<span></span>
<select value={imageScale} onChange={(event) => setImageScale(event.target.value as ImageScale)}>
<option value="2x">2x</option>
<option value="4x">4x</option>
</select>
</label>
<div className="resolution-upscale-scale-options" role="radiogroup" aria-label="放大倍数">
{(["2x", "4x"] as ImageScale[]).map((scale) => (
<button
key={scale}
type="button"
className={`resolution-upscale-scale-option${imageScale === scale ? " is-active" : ""}`}
aria-pressed={imageScale === scale}
onClick={() => setImageScale(scale)}
>
<strong>{scale}</strong>
<span>{scale === "2x" ? "日常清晰增强" : "高倍细节修复"}</span>
</button>
))}
</div>
) : (
<>
<div className="resolution-upscale-style-chips">
@@ -548,6 +555,7 @@ function ResolutionUpscalePage({
resultLabel={resultPreview ? resultSizeText : "等待结果"}
sourceAlt="原图预览"
resultAlt="超分结果预览"
aspectRatio={sourceDimensions ? `${sourceDimensions.width} / ${sourceDimensions.height}` : undefined}
onSourceLoad={(width, height) => setSourceDimensions({ width, height })}
/>
{resultPreview && (
@@ -360,7 +360,7 @@ function SubtitleRemovalPage({
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -339,7 +339,7 @@ function WatermarkRemovalPage({
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -21,8 +21,22 @@ interface ConversationSidebarProps {
}
function formatRelativeTime(dateStr: string): string {
const relativeMatch = dateStr.trim().match(/^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|mo|month|months|y|yr|year|years)\s+ago$/i);
if (relativeMatch) {
const value = Number(relativeMatch[1]);
const unit = relativeMatch[2].toLowerCase();
if (unit.startsWith("s")) return "刚刚";
if (unit === "m" || unit.startsWith("min")) return `${value} 分钟前`;
if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) return `${value} 小时前`;
if (unit === "d" || unit.startsWith("day")) return `${value} 天前`;
if (unit === "w" || unit.startsWith("week")) return `${value} 周前`;
if (unit === "mo" || unit.startsWith("month")) return `${value} 个月前`;
if (unit === "y" || unit.startsWith("yr") || unit.startsWith("year")) return `${value} 年前`;
}
const now = Date.now();
const then = new Date(dateStr).getTime();
if (!Number.isFinite(then)) return dateStr;
const diff = now - then;
if (diff < 60_000) return "刚刚";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
+18 -5
View File
@@ -25,13 +25,26 @@ interface ProjectSidebarProps {
}
function formatRelativeTime(dateStr: string): string {
const relativeMatch = dateStr.trim().match(/^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|mo|month|months|y|yr|year|years)\s+ago$/i);
if (relativeMatch) {
const value = Number(relativeMatch[1]);
const unit = relativeMatch[2].toLowerCase();
if (unit.startsWith("s")) return "刚刚";
if (unit === "m" || unit.startsWith("min")) return `${value} 分钟前`;
if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) return `${value} 小时前`;
if (unit === "d" || unit.startsWith("day")) return `${value} 天前`;
if (unit === "w" || unit.startsWith("week")) return `${value} 周前`;
if (unit === "mo" || unit.startsWith("month")) return `${value} 个月前`;
if (unit === "y" || unit.startsWith("yr") || unit.startsWith("year")) return `${value} 年前`;
}
const then = new Date(dateStr).getTime();
if (!Number.isFinite(then)) return "";
if (!Number.isFinite(then)) return dateStr;
const diff = Date.now() - then;
if (diff < 60_000) return "just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} h ago`;
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} d ago`;
if (diff < 60_000) return "刚刚";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} 天前`;
return new Date(dateStr).toLocaleDateString("zh-CN");
}
+21 -3
View File
@@ -1,4 +1,8 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import {
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
useSmoothedProgress,
type ProgressSource,
} from "../../hooks/useSmoothedProgress";
type MessageStatus = "thinking" | "completed" | "failed" | string;
@@ -6,6 +10,9 @@ interface SmoothedProgressBarProps {
progress: number;
status: MessageStatus;
label?: string;
progressSource?: ProgressSource;
startedAt?: number | string | Date | null;
expectedDurationMs?: number | null;
}
function mapMessageStatus(status: MessageStatus) {
@@ -15,8 +22,19 @@ function mapMessageStatus(status: MessageStatus) {
return "running" as const;
}
export function SmoothedProgressBar({ progress, status, label }: SmoothedProgressBarProps) {
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status));
export function SmoothedProgressBar({
progress,
status,
label,
progressSource = "estimated",
startedAt,
expectedDurationMs = DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
}: SmoothedProgressBarProps) {
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status), {
progressSource,
startedAt,
expectedDurationMs,
});
return (
<>
<span>{label || "超分处理中..."}</span>
+46 -24
View File
@@ -286,6 +286,7 @@ function WorkbenchPage({
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
const [promptCaseStatus, setPromptCaseStatus] = useState<"loading" | "ready" | "error">("loading");
const [promptCaseMeasuredRatios, setPromptCaseMeasuredRatios] = useState<Record<string, number>>({});
const [mentionPanelPlacement, setMentionPanelPlacement] = useState<"above" | "below">("above");
const [isGenerating, setIsGenerating] = useState(false);
@@ -757,6 +758,7 @@ function WorkbenchPage({
useEffect(() => {
let cancelled = false;
setPromptCaseStatus("loading");
communityClient
.listApprovedCases({ limit: 100, tag: "生成页面社区", sort: "latest" })
.then((items) => {
@@ -766,10 +768,12 @@ function WorkbenchPage({
.map(communityCaseToPromptCase)
.filter((item): item is PromptCaseViewModel => Boolean(item)),
);
setPromptCaseStatus("ready");
})
.catch(() => {
if (!cancelled) {
setServerPromptCases([]);
setPromptCaseStatus("error");
}
});
@@ -3410,30 +3414,48 @@ function WorkbenchPage({
<div className="wb-showcase__header">
<h2></h2>
</div>
<div className="wb-prompt-cases__grid">
{promptCaseDisplayItems.map((item, index) => {
const measuredRatio = promptCaseMeasuredRatios[item.id];
return (
<button
key={item.id}
type="button"
className={getPromptCaseCardClassName(item, index, measuredRatio)}
onClick={() => setSelectedPromptCase(item)}
>
<img
src={item.imageUrl}
alt={item.title}
loading="lazy"
onLoad={(event) => handlePromptCaseImageLoad(item.id, event)}
/>
<div>
<strong>{item.title}</strong>
<em>{item.author}</em>
</div>
</button>
);
})}
</div>
{promptCaseStatus === "loading" ? (
<div className="wb-prompt-cases__grid wb-prompt-cases__grid--skeleton" aria-label="图片提示词案例加载中">
{Array.from({ length: 8 }, (_, index) => (
<span key={index} className={`wb-prompt-case-skeleton wb-prompt-case-skeleton--${index % 4}`} />
))}
</div>
) : promptCaseStatus === "error" ? (
<div className="wb-prompt-cases__state">
<strong></strong>
<span></span>
</div>
) : promptCaseDisplayItems.length === 0 ? (
<div className="wb-prompt-cases__state">
<strong></strong>
<span></span>
</div>
) : (
<div className="wb-prompt-cases__grid">
{promptCaseDisplayItems.map((item, index) => {
const measuredRatio = promptCaseMeasuredRatios[item.id];
return (
<button
key={item.id}
type="button"
className={getPromptCaseCardClassName(item, index, measuredRatio)}
onClick={() => setSelectedPromptCase(item)}
>
<img
src={item.imageUrl}
alt={item.title}
loading="lazy"
onLoad={(event) => handlePromptCaseImageLoad(item.id, event)}
/>
<div>
<strong>{item.title}</strong>
<em>{item.author}</em>
</div>
</button>
);
})}
</div>
)}
</section>
</div>
@@ -19,7 +19,13 @@ import { assetClient } from "../../../api/assetClient";
import { communityClient } from "../../../api/communityClient";
import { saveAssetToLocalLibrary } from "../../assets/localAssetStore";
import { SmoothedProgressBar } from "../SmoothedProgressBar";
import { useSmoothedProgress } from "../../../hooks/useSmoothedProgress";
import {
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
formatEstimatedRemainingLabel,
useSmoothedProgress,
} from "../../../hooks/useSmoothedProgress";
import { renderMarkdownBlocks } from "../markdownRenderer";
import { downloadResultAsset } from "../workbenchDownload";
import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes";
@@ -456,6 +462,8 @@ export const ResultCard = memo(function ResultCard({
progress={message.taskProgress ?? 18}
status={message.status || "thinking"}
label={message.taskStatusLabel || "超分处理中..."}
progressSource="estimated"
expectedDurationMs={DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS}
/>
</div>
) : null}
@@ -575,7 +583,23 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
const specs = message.result?.specs || [];
const prompt = message.prompt || message.body;
const isVideo = message.mode === "video";
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, message.status === "thinking" ? "running" : "completed");
const expectedDurationMs = isVideo
? DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS
: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
const progressStatus = message.status === "thinking" ? "running" : "completed";
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, progressStatus, {
progressSource: progressStatus === "running" ? "estimated" : "real",
startedAt: message.createdAt,
expectedDurationMs,
});
const remainingLabel = progressStatus === "running"
? formatEstimatedRemainingLabel({
nowMs: Date.now(),
startedAt: message.createdAt,
expectedDurationMs,
})
: null;
const statusLabel = message.taskStatusLabel || (isVideo ? "视频生成中" : "图片生成中");
return (
<div className={`ai-generation-pending-card${isVideo ? " is-video" : " is-image"}`}>
@@ -590,7 +614,7 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
</div>
<div className="ai-generation-pending-card__meta">
<div>
<strong>{message.taskStatusLabel || "Generating..."}</strong>
<strong>{remainingLabel ? `${statusLabel} / ${remainingLabel}` : statusLabel}</strong>
<span>{prompt}</span>
</div>
{specs.length > 0 && (
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "../test/testHarness";
import {
calculateEstimatedProgress,
formatEstimatedRemainingLabel,
resolveProgressStartedAt,
} from "./useSmoothedProgress";
describe("useSmoothedProgress helpers", () => {
it("calculates estimated progress from elapsed time with an easing curve", () => {
expect(
Math.round(
calculateEstimatedProgress({
nowMs: 220_000,
startedAtMs: 100_000,
expectedDurationMs: 120_000,
}),
),
).toBe(85);
});
it("caps estimated progress below completion", () => {
expect(
Math.round(
calculateEstimatedProgress({
nowMs: 900_000,
startedAtMs: 0,
expectedDurationMs: 120_000,
}),
),
).toBe(92);
});
it("parses local workbench timestamps", () => {
expect(resolveProgressStartedAt("2026-06-10 09:30")).toBe(
new Date(2026, 5, 10, 9, 30).getTime(),
);
});
it("formats remaining time for estimated tasks", () => {
expect(
formatEstimatedRemainingLabel({
nowMs: new Date(2026, 5, 10, 9, 30).getTime(),
startedAt: "2026-06-10 09:29",
expectedDurationMs: 120_000,
}),
).toBe("预计还需 1 分钟");
});
});
+168 -5
View File
@@ -1,6 +1,14 @@
import { useEffect, useRef, useState } from "react";
type ProgressStatus = "queued" | "running" | "submitting" | "completed" | "failed" | "success" | "error";
type ProgressStatus =
| "queued"
| "running"
| "submitting"
| "completed"
| "failed"
| "success"
| "error";
export type ProgressSource = "real" | "estimated";
interface SmoothedProgressOptions {
creepSpeed?: number;
@@ -8,6 +16,11 @@ interface SmoothedProgressOptions {
creepCeiling?: number;
creepAhead?: number;
completionDuration?: number;
progressSource?: ProgressSource;
startedAt?: number | string | Date | null;
expectedDurationMs?: number | null;
estimatedFloor?: number;
estimatedCeiling?: number;
}
const DEFAULT_CREEP_SPEED = 0.5;
@@ -15,15 +28,109 @@ const DEFAULT_CHASE_RATE = 0.09;
const DEFAULT_CREEP_CEILING = 97;
const DEFAULT_CREEP_AHEAD = 25;
const DEFAULT_COMPLETION_DURATION = 450;
export const DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS = 120_000;
export const DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS = 240_000;
export const DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS = 180_000;
export const DEFAULT_GENERATION_EXPECTED_DURATION_MS =
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
const DEFAULT_ESTIMATED_FLOOR = 12;
const DEFAULT_ESTIMATED_CEILING = 92;
const ESTIMATED_PROGRESS_CURVE = 2.4;
const MIN_EXPECTED_DURATION_MS = 1_000;
function isTerminal(status: ProgressStatus): boolean {
return status === "completed" || status === "success" || status === "failed" || status === "error";
return (
status === "completed" ||
status === "success" ||
status === "failed" ||
status === "error"
);
}
function isSuccess(status: ProgressStatus): boolean {
return status === "completed" || status === "success";
}
export function resolveProgressStartedAt(
value: number | string | Date | null | undefined,
): number | null {
if (value instanceof Date) {
const time = value.getTime();
return Number.isFinite(time) ? time : null;
}
if (typeof value === "number") {
return Number.isFinite(value) && value > 0 ? value : null;
}
if (typeof value !== "string" || !value.trim()) return null;
const normalized = value.trim();
const localMatch = normalized.match(
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?/,
);
if (localMatch) {
const [, year, month, day, hours, minutes, seconds] = localMatch;
const time = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hours),
Number(minutes),
Number(seconds || 0),
).getTime();
return Number.isFinite(time) ? time : null;
}
const parsed = Date.parse(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
export function calculateEstimatedProgress(input: {
nowMs: number;
startedAtMs: number;
expectedDurationMs?: number | null;
floor?: number;
ceiling?: number;
}): number {
const floor = Math.max(
0,
Math.min(99, input.floor ?? DEFAULT_ESTIMATED_FLOOR),
);
const ceiling = Math.max(
floor,
Math.min(99, input.ceiling ?? DEFAULT_ESTIMATED_CEILING),
);
const duration = Math.max(
MIN_EXPECTED_DURATION_MS,
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
);
const elapsed = Math.max(0, input.nowMs - input.startedAtMs);
const ratio = elapsed / duration;
const eased = 1 - Math.exp(-ratio * ESTIMATED_PROGRESS_CURVE);
return Math.min(ceiling, floor + eased * (ceiling - floor));
}
export function formatEstimatedRemainingLabel(input: {
nowMs: number;
startedAt: number | string | Date | null | undefined;
expectedDurationMs?: number | null;
}): string | null {
const startedAtMs = resolveProgressStartedAt(input.startedAt);
if (!startedAtMs) return null;
const duration = Math.max(
MIN_EXPECTED_DURATION_MS,
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
);
const remainingSeconds = Math.max(
0,
Math.ceil((startedAtMs + duration - input.nowMs) / 1000),
);
if (remainingSeconds <= 0) return "即将完成";
if (remainingSeconds < 60) return `预计还需 ${remainingSeconds}`;
return `预计还需 ${Math.ceil(remainingSeconds / 60)} 分钟`;
}
/**
* Smoothly interpolates between coarse server-reported progress values.
* On completion, animates quickly to 100% instead of jumping.
@@ -37,24 +144,59 @@ export function useSmoothedProgress(
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD;
const completionDuration = options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
const completionDuration =
options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
const progressSource = options?.progressSource ?? "real";
const startedAt = options?.startedAt;
const expectedDurationMs =
options?.expectedDurationMs ?? DEFAULT_GENERATION_EXPECTED_DURATION_MS;
const estimatedFloor = options?.estimatedFloor ?? DEFAULT_ESTIMATED_FLOOR;
const estimatedCeiling =
options?.estimatedCeiling ?? DEFAULT_ESTIMATED_CEILING;
const [displayed, setDisplayed] = useState(0);
const rafRef = useRef(0);
const targetRef = useRef(realProgress);
const statusRef = useRef(status);
const progressSourceRef = useRef(progressSource);
const expectedDurationMsRef = useRef(expectedDurationMs);
const estimatedFloorRef = useRef(estimatedFloor);
const estimatedCeilingRef = useRef(estimatedCeiling);
const estimatedStartedAtRef = useRef<number | null>(null);
const completionStartRef = useRef<number | null>(null);
const completionBaseRef = useRef(0);
useEffect(() => {
targetRef.current = realProgress;
statusRef.current = status;
progressSourceRef.current = progressSource;
expectedDurationMsRef.current = expectedDurationMs;
estimatedFloorRef.current = estimatedFloor;
estimatedCeilingRef.current = estimatedCeiling;
if (progressSource === "estimated" && !isTerminal(status)) {
estimatedStartedAtRef.current =
resolveProgressStartedAt(startedAt) ??
estimatedStartedAtRef.current ??
Date.now();
}
if (isSuccess(status) && completionStartRef.current === null) {
completionStartRef.current = performance.now();
completionBaseRef.current = displayed;
} else if (!isSuccess(status)) {
completionStartRef.current = null;
}
}, [realProgress, status]);
}, [
displayed,
estimatedCeiling,
estimatedFloor,
expectedDurationMs,
progressSource,
realProgress,
startedAt,
status,
]);
useEffect(() => {
if (status === "failed" || status === "error") {
@@ -82,6 +224,20 @@ export function useSmoothedProgress(
if (isTerminal(currentStatus)) return current;
if (progressSourceRef.current === "estimated") {
const target = calculateEstimatedProgress({
nowMs: Date.now(),
startedAtMs: estimatedStartedAtRef.current ?? Date.now(),
expectedDurationMs: expectedDurationMsRef.current,
floor: estimatedFloorRef.current,
ceiling: estimatedCeilingRef.current,
});
if (current >= target) return current;
const gap = target - current;
const step = (gap * chaseRate + 0.2) * dt * 60;
return Math.min(current + step, target);
}
const target = targetRef.current;
if (current >= target) {
@@ -100,7 +256,14 @@ export function useSmoothedProgress(
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [status, chaseRate, creepSpeed, creepCeiling, creepAhead, completionDuration]);
}, [
status,
chaseRate,
creepSpeed,
creepCeiling,
creepAhead,
completionDuration,
]);
if (status === "failed" || status === "error") return Math.round(displayed);
if (isSuccess(status) && displayed >= 99.5) return 100;
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it } from "../test/testHarness";
import { useGenerationStore } from "./useGenerationStore";
import type { WebGenerationPreviewTask } from "../types";
function previewTask(id: string, status: WebGenerationPreviewTask["status"] = "running"): WebGenerationPreviewTask {
return {
id,
title: "Task",
type: "image",
status,
progress: status === "completed" ? 100 : 10,
prompt: "prompt",
createdAt: "2026-06-10T08:00:00.000Z",
source: "server",
};
}
describe("useGenerationStore task state", () => {
afterEach(() => {
useGenerationStore.setState({ queue: [], tasks: [] });
});
it("merges server preview tasks without duplicating local rows", () => {
const store = useGenerationStore.getState();
store.appendTask(previewTask("server-1"));
store.mergeServerTasks([previewTask("server-1", "completed"), previewTask("server-2")]);
const tasks = useGenerationStore.getState().tasks;
expect(tasks.map((task) => task.id)).toEqual(["server-1", "server-2"]);
expect(tasks[0].status).toBe("completed");
});
it("syncs running queue updates into matching preview tasks", () => {
const store = useGenerationStore.getState();
store.addTask({
id: "local-task-1",
taskId: "server-task-1",
title: "Image",
type: "image",
status: "running",
progress: 5,
prompt: "prompt",
createdAt: Date.now(),
sourceView: "workbench",
});
expect(useGenerationStore.getState().tasks[0].id).toBe("server-task-1");
expect(useGenerationStore.getState().tasks[0].status).toBe("running");
store.updateTask("local-task-1", {
status: "completed",
progress: 100,
resultUrl: "https://oss.example/result.png",
});
const task = useGenerationStore.getState().tasks[0];
expect(task.status).toBe("completed");
expect(task.progress).toBe(100);
expect(task.outputUrl).toBe("https://oss.example/result.png");
});
it("clears preview tasks and running queue together", () => {
const store = useGenerationStore.getState();
store.appendTask(previewTask("server-task-1"));
store.addTask({
id: "local-task-1",
title: "Image",
type: "image",
status: "running",
progress: 5,
prompt: "prompt",
createdAt: Date.now(),
sourceView: "workbench",
});
store.clearTasks();
expect(useGenerationStore.getState().tasks).toEqual([]);
expect(useGenerationStore.getState().queue).toEqual([]);
});
});
+112 -2
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import type { WebGenerationPreviewTask } from "../types";
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
@@ -17,6 +18,8 @@ export interface GenerationQueueItem {
params?: Record<string, unknown>;
}
type PreviewTaskPatch = Partial<WebGenerationPreviewTask>;
interface PersistedQueueSnapshot {
version: 1;
items: GenerationQueueItem[];
@@ -53,9 +56,14 @@ function persistQueue(items: GenerationQueueItem[]): void {
interface GenerationStoreState {
queue: GenerationQueueItem[];
tasks: WebGenerationPreviewTask[];
addTask: (item: GenerationQueueItem) => void;
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
removeTask: (id: string) => void;
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
appendTask: (task: WebGenerationPreviewTask) => void;
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
clearTasks: () => void;
getRunningTasks: () => GenerationQueueItem[];
getPendingTasks: () => GenerationQueueItem[];
getTasksByView: (sourceView: string) => GenerationQueueItem[];
@@ -64,14 +72,87 @@ interface GenerationStoreState {
const initialQueue = loadPersistedQueue();
function trimTasks(tasks: WebGenerationPreviewTask[]): WebGenerationPreviewTask[] {
return tasks.slice(0, MAX_ITEMS);
}
function mergePreviewTaskById(
tasks: WebGenerationPreviewTask[],
taskId: string | undefined,
patch: PreviewTaskPatch,
): WebGenerationPreviewTask[] {
if (!taskId) return tasks;
let changed = false;
const next = tasks.map((task) => {
if (task.id !== taskId) return task;
changed = true;
return { ...task, ...patch };
});
return changed ? next : tasks;
}
function toPreviewTaskStatus(status: GenerationQueueItem["status"]): WebGenerationPreviewTask["status"] {
if (status === "pending") return "queued";
if (status === "cancelled") return "failed";
return status;
}
function toPreviewTaskPatch(item: GenerationQueueItem): PreviewTaskPatch {
const status = toPreviewTaskStatus(item.status);
return {
status,
progress: item.status === "completed" ? 100 : item.progress,
outputUrl: item.resultUrl || undefined,
errorMessage: item.error || undefined,
};
}
function toPreviewTask(item: GenerationQueueItem): WebGenerationPreviewTask | null {
if (item.type === "ecommerce-video") return null;
const type = item.type;
const createdAt = Number.isFinite(item.createdAt)
? new Date(item.createdAt).toISOString()
: new Date().toISOString();
return {
id: item.taskId || item.id,
title: item.title,
type,
status: toPreviewTaskStatus(item.status),
progress: item.status === "completed" ? 100 : item.progress,
prompt: item.prompt,
createdAt,
projectId:
typeof item.params?.projectId === "string" ? item.params.projectId : undefined,
outputUrl: item.resultUrl || undefined,
source: "preview",
errorMessage: item.error || undefined,
};
}
function upsertPreviewTask(
tasks: WebGenerationPreviewTask[],
task: WebGenerationPreviewTask | null,
): WebGenerationPreviewTask[] {
if (!task) return tasks;
return trimTasks([task, ...tasks.filter((item) => item.id !== task.id)]);
}
function previewTaskIdsForItem(item: GenerationQueueItem): string[] {
return Array.from(new Set([item.taskId, item.id].filter(Boolean) as string[]));
}
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
queue: initialQueue,
tasks: [],
addTask: (item) => {
set((state) => {
const next = [item, ...state.queue].slice(0, MAX_ITEMS);
const previewTasks = upsertPreviewTask(state.tasks, toPreviewTask(item));
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
return { queue: next };
return { queue: next, tasks: previewTasks };
});
},
@@ -80,8 +161,16 @@ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
const next = state.queue.map((item) =>
item.id === id ? { ...item, ...patch } : item,
);
const updated = next.find((item) => item.id === id);
let previewTasks = state.tasks;
if (updated) {
const previewPatch = toPreviewTaskPatch(updated);
for (const previewTaskId of previewTaskIdsForItem(updated)) {
previewTasks = mergePreviewTaskById(previewTasks, previewTaskId, previewPatch);
}
}
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
return { queue: next };
return { queue: next, tasks: previewTasks };
});
},
@@ -93,6 +182,27 @@ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
});
},
setTasks: (tasks) => set({ tasks: trimTasks(tasks) }),
appendTask: (task) => set((state) => ({
tasks: trimTasks([task, ...state.tasks.filter((item) => item.id !== task.id)]),
})),
mergeServerTasks: (serverTasks) => set((state) => {
const serverIds = new Set(serverTasks.map((task) => task.id));
return {
tasks: trimTasks([
...serverTasks,
...state.tasks.filter((task) => !serverIds.has(task.id)),
]),
};
}),
clearTasks: () => {
persistQueue([]);
set({ tasks: [], queue: [] });
},
getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"),
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
+1 -36
View File
@@ -1,36 +1 @@
import { create } from 'zustand';
import type { WebGenerationPreviewTask } from '../types';
interface TaskState {
tasks: WebGenerationPreviewTask[];
}
interface TaskActions {
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
appendTask: (task: WebGenerationPreviewTask) => void;
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
clearTasks: () => void;
}
const initialState: TaskState = {
tasks: [],
};
export const useTaskStore = create<TaskState & TaskActions>((set) => ({
...initialState,
setTasks: (tasks) => set({ tasks }),
appendTask: (task) => set((state) => ({
tasks: [task, ...state.tasks],
})),
mergeServerTasks: (serverTasks) => set((state) => {
const serverIds = new Set(serverTasks.map((task) => task.id));
return {
tasks: [...serverTasks, ...state.tasks.filter((task) => !serverIds.has(task.id))].slice(0, 80),
};
}),
clearTasks: () => set({ tasks: [] }),
}));
export { useGenerationStore as useTaskStore } from "./useGenerationStore";
File diff suppressed because it is too large Load Diff
+46 -22
View File
@@ -2,50 +2,74 @@
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
margin-top: 0;
}
.subtitle-removal-preset {
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: auto auto 1fr;
justify-items: center;
align-items: center;
gap: 4px;
gap: 6px;
min-width: 0;
min-height: 92px;
padding: 10px 8px;
border: 1.5px solid var(--border-weak, #e5e5e5);
border-radius: 10px;
background: var(--bg-surface, #fff);
border: 1px solid color-mix(in srgb, var(--border-subtle, #333) 82%, white 10%);
border-radius: var(--radius-xs, 8px);
background:
linear-gradient(180deg, rgb(255 255 255 / 0.018), transparent),
var(--bg-inset, #111);
color: var(--fg-muted, #aaa);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
.subtitle-removal-preset:hover {
border-color: var(--accent, #0d9488);
background: var(--bg-elevated, #f8f8f8);
border-color: color-mix(in srgb, var(--accent, #0d9488) 42%, var(--border-subtle, #333));
background:
linear-gradient(180deg, color-mix(in srgb, var(--accent, #0d9488) 8%, transparent), transparent),
var(--bg-hover, #171717);
color: var(--fg-body, #eee);
transform: translateY(-1px);
}
.subtitle-removal-preset.is-active {
border-color: var(--accent, #0d9488);
background: color-mix(in srgb, var(--accent, #0d9488) 6%, transparent);
border-color: color-mix(in srgb, var(--accent, #0d9488) 72%, transparent);
background: color-mix(in srgb, var(--accent, #0d9488) 13%, var(--bg-inset, #111));
color: var(--accent, #0d9488);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #0d9488) 8%, transparent) inset;
}
.subtitle-removal-preset strong {
max-width: 100%;
color: var(--fg-body, #eee);
font-size: 12px;
font-weight: 600;
font-weight: 800;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle-removal-preset span {
max-width: 100%;
color: var(--fg-muted, #aaa);
font-size: 11px;
opacity: 0.55;
line-height: 1.35;
opacity: 0.82;
text-align: center;
}
.subtitle-removal-preset__visual {
position: relative;
width: 48px;
height: 32px;
border-radius: 4px;
background: var(--bg-page, #f0f0f0);
border: 1px solid var(--border-weak, #e0e0e0);
width: 54px;
height: 34px;
border: 1px solid color-mix(in srgb, var(--border-subtle, #333) 78%, white 10%);
border-radius: 6px;
background:
linear-gradient(180deg, rgb(255 255 255 / 0.026), transparent),
color-mix(in srgb, var(--bg-elevated, #161616) 92%, black 8%);
box-shadow: 0 1px 0 rgb(255 255 255 / 0.035) inset;
overflow: hidden;
}
@@ -55,9 +79,9 @@
right: 0;
top: var(--region-top);
height: var(--region-height);
background: color-mix(in srgb, var(--accent, #0d9488) 30%, transparent);
border-top: 1.5px dashed var(--accent, #0d9488);
border-bottom: 1.5px dashed var(--accent, #0d9488);
background: color-mix(in srgb, var(--accent, #0d9488) 24%, transparent);
border-top: 1px dashed var(--accent, #0d9488);
border-bottom: 1px dashed var(--accent, #0d9488);
}
.subtitle-removal-preview {
+666
View File
@@ -2319,3 +2319,669 @@
display: none;
}
}
/* Workbench new-conversation commercial polish. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page {
--wb-elevated-line: rgba(210, 255, 232, 0.12);
--wb-elevated-line-active: rgba(var(--accent-rgb), 0.38);
--wb-elevated-fill: rgba(18, 22, 21, 0.94);
--wb-control-fill: rgba(255, 255, 255, 0.052);
--wb-control-fill-hover: rgba(var(--accent-rgb), 0.105);
--wb-copy-dim: rgba(202, 215, 207, 0.68);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
gap: 20px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__title {
color: rgba(246, 250, 247, 0.96);
font-weight: 760;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__content {
border-color: var(--wb-elevated-line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.072), rgba(255, 255, 255, 0.024)),
var(--wb-elevated-fill);
box-shadow:
0 20px 46px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.065);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__content:focus-within {
border-color: var(--wb-elevated-line-active);
box-shadow:
0 0 0 1px rgba(var(--accent-rgb), 0.12),
0 24px 56px rgba(0, 0, 0, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__highlight {
color: rgba(246, 250, 247, 0.95);
font-weight: 450;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar {
align-items: center;
gap: 10px;
border-top-color: rgba(210, 255, 232, 0.095);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-left {
row-gap: 8px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-inline-chip__trigger,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-mode-switch__button,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-select-chip__trigger {
border-color: rgba(210, 255, 232, 0.105);
background: var(--wb-control-fill);
color: rgba(246, 250, 247, 0.86);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-inline-chip__trigger:hover:not(:disabled),
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-select-chip__trigger:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-mode-switch__button:hover {
border-color: rgba(var(--accent-rgb), 0.34);
background: var(--wb-control-fill-hover);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__billing-estimate {
color: var(--wb-copy-dim);
font-size: 11px;
font-weight: 580;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__send-primary {
box-shadow:
0 12px 26px rgba(var(--accent-rgb), 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.26);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__send-primary.is-loading {
cursor: progress;
opacity: 0.92;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-upload {
border-color: rgba(var(--accent-rgb), 0.36);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.11), rgba(var(--accent-rgb), 0.045)),
rgba(255, 255, 255, 0.02);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-label,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-count {
color: rgba(210, 255, 232, 0.84);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__suggestions {
align-items: center;
margin-top: -2px;
min-height: 34px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-suggestion-chip {
border-color: rgba(210, 255, 232, 0.105);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.022)),
rgba(255, 255, 255, 0.018);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-suggestion-chip__icon {
color: rgba(var(--accent-rgb), 0.88);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases {
padding-bottom: 8px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases .wb-showcase__header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 24px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-showcase__header h2 {
color: rgba(224, 236, 229, 0.72);
font-size: 12px;
font-weight: 680;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card {
border-color: rgba(210, 255, 232, 0.085);
background: #090e0d;
box-shadow:
0 14px 28px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card:focus-visible {
border-color: rgba(var(--accent-rgb), 0.32);
box-shadow:
0 18px 36px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(var(--accent-rgb), 0.08);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card > div {
gap: 5px;
background:
linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.54) 24%, rgba(0, 0, 0, 0.9));
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card strong {
color: rgba(255, 255, 255, 0.95);
line-height: 1.38;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card em {
color: rgba(210, 224, 216, 0.68);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state {
display: grid;
place-items: center;
align-content: center;
gap: 6px;
min-height: 188px;
padding: 26px;
border: 1px dashed rgba(210, 255, 232, 0.13);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.014)),
rgba(255, 255, 255, 0.018);
color: rgba(246, 250, 247, 0.9);
text-align: center;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state strong {
font-size: 14px;
font-weight: 720;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state span {
color: var(--wb-copy-dim);
font-size: 12px;
font-weight: 520;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton {
display: block;
min-height: 160px;
grid-row: span 16;
overflow: hidden;
border: 1px solid rgba(210, 255, 232, 0.07);
border-radius: 14px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.035)),
rgba(255, 255, 255, 0.025);
background-size: 220% 100%;
animation: wb-prompt-case-loading 1.15s ease-in-out infinite;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton--1,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton--3 {
grid-row: span 22;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .ai-chat-message-list > .conversation-sidebar__empty {
border-color: rgba(210, 255, 232, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.018)),
rgba(255, 255, 255, 0.018);
color: rgba(210, 224, 216, 0.78);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar {
border-left-color: rgba(210, 255, 232, 0.095);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 40%),
rgba(12, 15, 15, 0.97);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__header {
border-bottom-color: rgba(210, 255, 232, 0.095);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__new {
border-color: rgba(var(--accent-rgb), 0.38);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.17), rgba(var(--accent-rgb), 0.095)),
rgba(255, 255, 255, 0.02);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__item {
transition:
border-color var(--transition-fast),
background var(--transition-fast),
transform var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__item:hover {
transform: translateX(-1px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__item-title {
line-height: 1.35;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__empty {
border-color: rgba(210, 255, 232, 0.12);
background: rgba(255, 255, 255, 0.022);
}
@keyframes wb-prompt-case-loading {
0% {
background-position: 120% 0;
}
100% {
background-position: -120% 0;
}
}
@media (max-width: 720px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .ai-workbench-shell > .conversation-sidebar.is-collapsed {
inset: calc(56px + var(--dg-mobile-nav-space, 70px) + 16px) 8px auto auto !important;
transform: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
gap: 16px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__composer {
width: 100%;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__content,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__content {
padding: 13px;
border-radius: 18px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs {
width: 60px;
min-width: 60px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-upload {
width: 58px;
height: 58px;
transform: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-upload:hover:not(:disabled) {
transform: translateY(-1px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__highlight,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__highlight {
min-height: 88px;
padding-right: 0;
font-size: 15px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
padding-top: 9px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-left {
max-width: none;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-left::-webkit-scrollbar,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__suggestions::-webkit-scrollbar {
display: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-right {
align-self: stretch;
display: grid;
grid-template-rows: 1fr auto;
justify-items: end;
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__billing-estimate {
max-width: 112px;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases {
margin-top: 0;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state {
min-height: 156px;
border-radius: 14px;
}
}
@media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
padding-right: 12px;
padding-left: 12px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__title {
font-size: 23px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__input-row {
gap: 9px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-inline-chip__trigger,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-mode-switch__button,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-select-chip__trigger {
height: 32px;
max-width: 132px;
border-radius: 10px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-suggestion-chip {
min-height: 32px;
height: 32px;
padding: 0 12px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__grid {
gap: 7px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card {
border-radius: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton {
animation: none;
}
}
/* Browser feedback: scale the launch composer with large canvases and keep reference previews intact. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__composer {
width: min(100%, clamp(920px, 72vw, 1160px));
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__content {
padding: clamp(18px, 1.25vw, 24px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__highlight {
min-height: clamp(78px, 8svh, 112px);
max-height: clamp(180px, 24svh, 260px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__suggestions {
max-width: min(100%, clamp(920px, 72vw, 1160px));
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card {
isolation: isolate;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom {
z-index: 12;
width: min(280px, calc(100vw - 48px));
height: auto;
min-height: 188px;
max-height: min(340px, calc(100svh - 180px));
aspect-ratio: 1 / 1;
padding: 8px;
border-color: rgba(210, 255, 232, 0.18);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
rgba(5, 8, 8, 0.96);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom img,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom video {
border-radius: 11px;
object-fit: contain;
}
@media (min-width: 1600px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__composer,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__suggestions {
width: min(100%, clamp(1040px, 64vw, 1240px));
max-width: none;
}
}
@media (max-width: 980px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__composer,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__suggestions {
width: 100%;
max-width: 920px;
}
}
@media (max-width: 720px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__content {
padding: 13px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__highlight {
min-height: 88px;
max-height: 190px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom {
width: min(230px, calc(100vw - 32px));
min-height: 168px;
}
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-stack {
top: calc(100% + 12px);
bottom: auto;
isolation: isolate;
z-index: 120;
width: min(320px, calc(100vw - 64px));
max-width: calc(100vw - 64px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-zoom {
top: 0;
transform: translateY(0) scale(0.98);
transform-origin: left top;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-card:hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
/* Keep reference previews above the feed, and open lower-row thumbnails upward. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__composer {
position: relative;
z-index: 30;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__suggestions {
position: relative;
z-index: 10;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases {
position: relative;
z-index: 1;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs.has-items.is-open {
z-index: 220;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
z-index: 240;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-zoom {
top: auto;
bottom: calc(100% + 12px);
transform: translateY(-6px) scale(0.98);
transform-origin: left bottom;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5):hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
@media (max-width: 720px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 4) .wb-composer__ref-zoom {
top: auto;
bottom: calc(100% + 10px);
transform: translateY(-6px) scale(0.98);
transform-origin: left bottom;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 4):hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 4) .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
isolation: isolate;
z-index: 120;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card {
isolation: auto;
z-index: 2;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-add-more {
position: relative;
z-index: 1;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:focus-within {
z-index: 180;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-zoom {
left: 0;
top: calc(100% + 12px);
z-index: 80;
transform: translateY(6px) scale(0.98);
transform-origin: left top;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
@media (max-width: 720px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-stack {
top: calc(100% + 8px);
width: min(230px, calc(100vw - 24px));
max-width: calc(100vw - 24px);
}
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:focus-within {
z-index: 140;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:hover .wb-composer__ref-preview,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:focus-within .wb-composer__ref-preview {
border-color: rgba(var(--accent-rgb), 0.48);
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.18);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card {
isolation: auto;
z-index: 2;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-add-more {
position: relative;
z-index: 1;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:focus-within {
z-index: 180;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-zoom {
left: 0;
top: calc(100% + 12px);
z-index: 80;
transform: translateY(6px) scale(0.98);
transform-origin: left top;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-card:hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
/* Final override for multi-row reference stacks on the launch composer. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-zoom {
top: auto;
bottom: calc(100% + 12px);
transform: translateY(-6px) scale(0.98);
transform-origin: left bottom;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5):hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
/* Keep the reference stack balanced in the active bottom composer. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
grid-template-columns: repeat(4, 58px);
width: min(286px, calc(100vw - 40px));
}
@media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
grid-template-columns: repeat(3, 58px);
width: min(218px, calc(100vw - 24px));
}
}
/* The active bottom composer sits below the upload stack, so previews should open upward. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .wb-composer__refs .wb-composer__ref-card .wb-composer__ref-zoom {
top: auto;
bottom: calc(100% + 12px);
transform: translateY(-6px) scale(0.98);
transform-origin: left bottom;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .wb-composer__refs .wb-composer__ref-card:hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .wb-composer__refs .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}