统一智能体对话层级并完善素材导出与文档预览
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m44s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m45s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m18s
Project CI / Frontend tests (pull_request) Failing after 4m6s
Project CI / Native shell tests (pull_request) Successful in 6m12s
Project CI / Repository checks (pull_request) Failing after 3m48s
Project CI / Backend tests (pull_request) Successful in 7m8s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m59s

当前Agent与策划Agent共用正文和过程表现组件,统一思考及工具调用的小字号浅色样式
素材工具栏按实际动作分组,导出紧邻删除并修复空分组重复分隔线
文档与代码使用共享Markdown预览和代码高亮,复用按需读取与缓存
移除未调用的活跃回合查询命令,保留正式快照恢复链路
补充回归用例并同步技术方案和团队约定
This commit is contained in:
2026-09-16 03:59:27 +08:00
parent d3c117de01
commit 5cd02aa8b0
37 changed files with 1439 additions and 239 deletions
+1
View File
@@ -57,6 +57,7 @@
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
"zustand": "^5.0.14"
@@ -444,7 +444,8 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result<Stri
})
}
/// `read_direct_codex_active_turn` 的返回值:前端重进会话时用它恢复正在跑的回合
/// 测试读取进程内调用守卫快照,校验占用与释放生命周期
#[cfg(test)]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectActiveTurnView {
@@ -458,6 +459,7 @@ pub(crate) struct DirectActiveTurnView {
///
/// 只读,不改变互斥语义与释放时机:守卫仍然只由回合自己的 `Drop` 或
/// [`release_stale_direct_taonier_active_invocation`] 释放。
#[cfg(test)]
pub(crate) fn read_direct_taonier_active_invocation_at(
root: &Path,
) -> Result<Option<DirectActiveTurnView>, String> {
@@ -475,16 +477,6 @@ pub(crate) fn read_direct_taonier_active_invocation_at(
}))
}
/// 只读命令:读取该项目当前登记的 Direct 活跃回合,供前端重进会话时恢复。
///
/// 不进入、不抢占、不释放守卫;没有回合时返回 `null`。
#[tauri::command]
pub(crate) fn read_direct_codex_active_turn(
project_path: String,
) -> Result<Option<DirectActiveTurnView>, String> {
read_direct_taonier_active_invocation_at(Path::new(project_path.trim()))
}
/// "终止"拿不到可中断句柄时的分类,决定是否允许强制释放本地守卫。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum DirectTaonierStaleGuardReason {
@@ -2677,7 +2677,6 @@ fn main() {
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
chat_with_game_creator_direct_codex,
read_direct_codex_active_turn,
cancel_direct_codex_turn,
select_game_creator_reasoning_effort,
start_planning_session_v2,
@@ -1204,19 +1204,6 @@ interface TurnStreamItemBase {
*/
export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem;
/**
* `read_direct_codex_active_turn` 的返回值:Rust 进程内当前登记的 Direct 活跃回合。
*
* 重进会话时用它把前端的"当前活跃回合"接管回来,否则界面不知道有回合在跑,
* 既不显示过程卡也不给终止入口,用户再发消息只会被守卫拒绝。
*/
export interface DirectActiveTurnView {
/** 与回合事件的 `turnId` 同一个身份。 */
clientTurnId: string;
/** 这一轮登记的时刻(Unix 毫秒)。 */
startedAt: number;
}
/** `cancel_direct_codex_turn` 的返回值。 */
export interface DirectTurnCancelView {
/**
@@ -0,0 +1,37 @@
/* 只作用于共享 Markdown 渲染器,不改变普通正文及用户消息的字体颜色。 */
.agc-markdown-code .hljs-comment,
.agc-markdown-code .hljs-quote {
color: #6a737d;
}
.agc-markdown-code .hljs-keyword,
.agc-markdown-code .hljs-name,
.agc-markdown-code .hljs-selector-tag,
.agc-markdown-code .hljs-literal,
.agc-markdown-code .hljs-deletion {
color: #a6264c;
}
.agc-markdown-code .hljs-string,
.agc-markdown-code .hljs-regexp,
.agc-markdown-code .hljs-addition {
color: #276438;
}
.agc-markdown-code .hljs-number,
.agc-markdown-code .hljs-attr,
.agc-markdown-code .hljs-variable,
.agc-markdown-code .hljs-built_in {
color: #075a9c;
}
.agc-markdown-code .hljs-title,
.agc-markdown-code .hljs-type,
.agc-markdown-code .hljs-section {
color: #6f42a0;
}
.agc-markdown-code .hljs-meta,
.agc-markdown-code .hljs-symbol {
color: #8a4c0a;
}
@@ -1,3 +1,5 @@
import './codeHighlight.css';
import type { ErrorInfo, ReactNode } from 'react';
import {
Children,
@@ -7,14 +9,20 @@ import {
useContext,
} from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import remarkGfm from 'remark-gfm';
export type ChatMarkdownMessageProps = {
text: string;
role: 'assistant' | 'user';
streaming?: boolean;
/** 文件预览不压缩正文空行,保留源码与文档的原始排版。 */
preserveBlankLines?: boolean;
};
const MAX_HIGHLIGHT_CHARACTERS = 100_000;
const CodeBlockContext = createContext(false);
/** 只压缩普通 Markdown 正文里多余的空行;代码块中的换行必须原样保留。 */
function normalizeMarkdownBlankLines(text: string) {
return text
@@ -183,22 +191,50 @@ const markdownComponents: Components = {
a: ({ children }) => children,
img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'),
h1: ({ children }) => (
<h1 className="m-0 mt-4 !text-xl font-bold first:mt-0">{children}</h1>
<h1
className="m-0 mt-4 font-bold first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 1.25rem)' }}
>
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="m-0 mt-4 !text-lg font-bold first:mt-0">{children}</h2>
<h2
className="m-0 mt-4 font-bold first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 1.125rem)' }}
>
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="m-0 mt-3 !text-base font-semibold first:mt-0">{children}</h3>
<h3
className="m-0 mt-3 font-semibold first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 1rem)' }}
>
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="m-0 mt-3 !text-sm font-semibold first:mt-0">{children}</h4>
<h4
className="m-0 mt-3 font-semibold first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 0.875rem)' }}
>
{children}
</h4>
),
h5: ({ children }) => (
<h5 className="m-0 mt-2 !text-sm font-medium first:mt-0">{children}</h5>
<h5
className="m-0 mt-2 font-medium first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 0.875rem)' }}
>
{children}
</h5>
),
h6: ({ children }) => (
<h6 className="m-0 mt-2 !text-xs font-medium uppercase tracking-wide first:mt-0">
<h6
className="m-0 mt-2 font-medium uppercase tracking-wide first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 0.75rem)' }}
>
{children}
</h6>
),
@@ -213,15 +249,18 @@ const markdownComponents: Components = {
),
pre: ({ children }) => (
<pre className="m-0 mt-2 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5 first:mt-0">
{children}
<CodeBlockContext.Provider value={true}>
{children}
</CodeBlockContext.Provider>
</pre>
),
code: ({ className, children, node: _node, ...props }) => {
const isBlock =
Boolean(className?.includes('language-')) ||
String(children).includes('\n');
code: function MarkdownCode({ className, children, node: _node, ...props }) {
const isBlock = useContext(CodeBlockContext);
return isBlock ? (
<code {...props} className="font-mono whitespace-pre">
<code
{...props}
className={`agc-markdown-code font-mono whitespace-pre ${className ?? ''}`}
>
{children}
</code>
) : (
@@ -235,7 +274,10 @@ const markdownComponents: Components = {
},
table: ({ children }) => (
<div className="mt-2 max-w-full overflow-x-auto first:mt-0">
<table className="min-w-full border-collapse text-left text-sm">
<table
className="min-w-full border-collapse text-left"
style={{ fontSize: 'var(--agent-message-table-size, 0.875rem)' }}
>
{children}
</table>
</div>
@@ -264,6 +306,7 @@ export function ChatMarkdownMessage({
text,
role,
streaming = false,
preserveBlankLines = false,
}: ChatMarkdownMessageProps) {
if (role === 'user') {
return <span className="whitespace-pre-wrap break-words">{text}</span>;
@@ -274,11 +317,14 @@ export function ChatMarkdownMessage({
<ReactMarkdown
skipHtml
remarkPlugins={[remarkGfm]}
rehypePlugins={
text.length <= MAX_HIGHLIGHT_CHARACTERS ? [rehypeHighlight] : []
}
components={
streaming ? streamingMarkdownComponents : markdownComponents
}
>
{normalizeMarkdownBlankLines(text)}
{preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
</ReactMarkdown>
</MarkdownErrorBoundary>
);
@@ -8,6 +8,10 @@ import type {
} from 'react';
import { Fragment, useEffect, useRef, useState } from 'react';
import {
AgentMessageContent,
type AgentMessageTone,
} from '../../../../../packages/shared/src/components/AgentMessageContent';
import type {
AgentStatusCard,
ChatMessage,
@@ -131,6 +135,7 @@ function TurnStreamSequence({
active,
userSentAt,
className,
tone = 'body',
}: {
items: readonly TurnStreamItem[];
toolCalls: readonly GameCreatorDirectToolCall[];
@@ -138,6 +143,7 @@ function TurnStreamSequence({
active: boolean;
userSentAt: number;
className?: string;
tone?: AgentMessageTone;
}) {
const runs = turnStreamRuns(items);
const callsById = new Map<string, GameCreatorDirectToolCall>();
@@ -159,11 +165,13 @@ function TurnStreamSequence({
: 'message message--assistant'
}
>
<ChatMarkdownMessage
role="assistant"
text={run.text}
streaming={active && index === runs.length - 1}
/>
<AgentMessageContent tone={tone}>
<ChatMarkdownMessage
role="assistant"
text={run.text}
streaming={active && index === runs.length - 1}
/>
</AgentMessageContent>
</div>
) : (
<ToolCallGroup
@@ -183,6 +191,30 @@ function TurnStreamSequence({
);
}
/** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */
function AgentReasoning({
text,
label = '思考过程',
testId,
}: {
text: string;
label?: string;
testId?: string;
}) {
return (
<AgentMessageContent
as="details"
tone="process"
className="design-agent-reasoning"
aria-label={label}
data-testid={testId}
>
<summary></summary>
<pre>{text}</pre>
</AgentMessageContent>
);
}
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
function directStatusTitle(status: string | null | undefined) {
@@ -437,7 +469,11 @@ export function ProjectSupervisorView({
</p>
);
};
const renderMessage = (message: ChatMessage, index: number) => {
const renderMessageContent = (
message: ChatMessage,
index: number,
tone: AgentMessageTone,
) => {
const sentAt =
message.role === 'user' ? directMessageTimestamp(message.updatedAt) : 0;
return (
@@ -445,10 +481,12 @@ export function ProjectSupervisorView({
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
<ChatMarkdownMessage
role={message.role}
text={projectSupervisorChatMessageText(message)}
/>
<AgentMessageContent tone={tone}>
<ChatMarkdownMessage
role={message.role}
text={projectSupervisorChatMessageText(message)}
/>
</AgentMessageContent>
{sentAt > 0 ? (
<time
className="message-sent-at"
@@ -459,17 +497,16 @@ export function ProjectSupervisorView({
</time>
) : null}
{showDesignReasoning && message.reasoningText ? (
<details
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary></summary>
<pre>{message.reasoningText}</pre>
</details>
<AgentReasoning
text={message.reasoningText}
label="策划 Agent 思考过程"
/>
) : null}
</div>
);
};
const renderMessage = (message: ChatMessage, index: number) =>
renderMessageContent(message, index, 'body');
const submitButton = (
<button
@@ -578,17 +615,21 @@ export function ProjectSupervisorView({
{directCodex
? directTurns.map((turn) => {
const content = splitDirectTurnContent(turn);
const renderStream = (items: TurnStreamItem[]) => (
const renderStream = (
items: TurnStreamItem[],
tone: AgentMessageTone = 'body',
) => (
<TurnStreamSequence
items={items}
toolCalls={turn.calls}
active={turn.active}
userSentAt={turn.startedAt}
tone={tone}
/>
);
const process =
turn.source === 'stream' ? (
renderStream(content.processItems)
renderStream(content.processItems, 'process')
) : (
<>
{turn.calls.length > 0 ? (
@@ -599,7 +640,9 @@ export function ProjectSupervisorView({
className="message-tool-call"
/>
) : null}
{content.processMessages.map(renderMessage)}
{content.processMessages.map((message, index) =>
renderMessageContent(message, index, 'process'),
)}
</>
);
const hasProcess =
@@ -641,11 +684,13 @@ export function ProjectSupervisorView({
aria-live="polite"
data-runtime-owned="true"
>
<ChatMarkdownMessage
role="assistant"
text={turn.transientReply}
streaming
/>
<AgentMessageContent>
<ChatMarkdownMessage
role="assistant"
text={turn.transientReply}
streaming
/>
</AgentMessageContent>
</div>
) : null}
{renderTurnUsage(turn)}
@@ -654,35 +699,23 @@ export function ProjectSupervisorView({
})
: visibleMessages.map(renderMessage)}
{directCodex && transientReasoning ? (
<details
className="design-agent-reasoning"
data-testid="live-reasoning"
>
<summary></summary>
<pre>{transientReasoning}</pre>
</details>
<AgentReasoning text={transientReasoning} testId="live-reasoning" />
) : null}
{showDesignReasoning &&
designReasoningEntries
.filter((entry) => !entry.messageId)
.map((entry) => (
<details
<AgentReasoning
key={`reasoning-${entry.id}`}
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary></summary>
<pre>{entry.text}</pre>
</details>
text={entry.text}
label="策划 Agent 思考过程"
/>
))}
{showDesignReasoning && designReasoning ? (
<details
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary></summary>
<pre>{designReasoning}</pre>
</details>
<AgentReasoning
text={designReasoning}
label="策划 Agent 思考过程"
/>
) : null}
{!directCodex && transientReply ? (
<div
@@ -693,11 +726,13 @@ export function ProjectSupervisorView({
aria-live="polite"
data-runtime-owned="true"
>
<ChatMarkdownMessage
role="assistant"
text={transientReply}
streaming
/>
<AgentMessageContent>
<ChatMarkdownMessage
role="assistant"
text={transientReply}
streaming
/>
</AgentMessageContent>
</div>
) : null}
</div>
@@ -768,7 +803,9 @@ export function ProjectSupervisorView({
directStatus !== 'completed' &&
directStatus !== 'failed' &&
(runtimePanelProps.controlBusy || Boolean(activeTurnId)) ? (
<section
<AgentMessageContent
as="section"
tone="process"
className={`project-supervisor-process-card${runtimePanelProps.controlBusy ? ' is-active' : ''}`}
aria-label="陶泥儿执行过程"
aria-live="polite"
@@ -825,7 +862,7 @@ export function ProjectSupervisorView({
) : null}
</div>
) : null}
</section>
</AgentMessageContent>
) : null}
<form
className={`project-supervisor-composer${
@@ -8,6 +8,7 @@ import {
} from 'lucide-react';
import { useEffect, useId, useState } from 'react';
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
import type { GameCreatorDirectToolCall } from '../../app/types';
import {
formatToolCallDuration,
@@ -93,7 +94,9 @@ export function ToolCallGroup({
.filter(Boolean)
.join('');
return (
<section
<AgentMessageContent
as="section"
tone="process"
className={
className
? `agent-tool-call-group ${className}`
@@ -146,7 +149,7 @@ export function ToolCallGroup({
))}
</ul>
</div>
</section>
</AgentMessageContent>
);
}
@@ -42,7 +42,7 @@
position: absolute;
z-index: 60;
display: inline-flex;
max-width: min(92vw, 640px);
max-width: min(92vw, 800px);
align-items: center;
flex-wrap: nowrap;
gap: 0.3rem;
@@ -909,6 +909,39 @@
}
}
.game-resource-document-preview {
display: flex;
flex-direction: column;
width: min(960px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
min-width: 0;
border: 1px solid var(--platform-surface-border);
border-radius: 16px;
overflow: hidden;
}
.game-resource-document-preview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--platform-surface-border);
}
.game-resource-document-preview__header strong {
min-width: 0;
overflow-wrap: anywhere;
}
.game-resource-document-preview__body {
min-height: 0;
min-width: 0;
padding: 20px;
overflow: auto;
overscroll-behavior: contain;
}
/* 资源面板:独立浮层面板(预览 / 上传 / 下载 / 多选)。 */
.game-resource-panel {
width: min(880px, calc(100% - 32px));
@@ -87,6 +87,7 @@ export type ResourceCanvasHostOverlayState = {
readonly isClassificationPanelOpen: boolean;
readonly isRenameDialogOpen: boolean;
readonly isRecoveryPanelOpen: boolean;
readonly isDocumentPreviewOpen?: boolean;
};
export function isResourceCanvasHostOverlayOpen(
@@ -97,7 +98,8 @@ export function isResourceCanvasHostOverlayOpen(
overlay.isGenerationPanelOpen ||
overlay.isClassificationPanelOpen ||
overlay.isRenameDialogOpen ||
overlay.isRecoveryPanelOpen
overlay.isRecoveryPanelOpen ||
Boolean(overlay.isDocumentPreviewOpen)
);
}
@@ -0,0 +1,80 @@
import {
projectResourceCardPreviewKind,
projectResourcePathExtension,
} from '../../view/project-development/resourceCardPreviewModel';
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
const CODE_LANGUAGES: Readonly<Record<string, string>> = {
ts: 'typescript',
tsx: 'typescript',
mts: 'typescript',
cts: 'typescript',
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
rs: 'rust',
py: 'python',
go: 'go',
java: 'java',
kt: 'kotlin',
kts: 'kotlin',
cs: 'csharp',
cpp: 'cpp',
cc: 'cpp',
cxx: 'cpp',
c: 'c',
h: 'c',
hpp: 'cpp',
swift: 'swift',
php: 'php',
rb: 'ruby',
lua: 'lua',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
ps1: 'powershell',
psm1: 'powershell',
json: 'json',
jsonc: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'ini',
xml: 'xml',
html: 'xml',
htm: 'xml',
vue: 'xml',
svelte: 'xml',
css: 'css',
scss: 'scss',
less: 'less',
sql: 'sql',
graphql: 'graphql',
gql: 'graphql',
};
export function isResourceDocumentPreviewable(resource: ProjectResource) {
const kind = projectResourceCardPreviewKind(resource);
return (
(kind === 'document' || kind === 'code') &&
(resource.content !== undefined || resource.path.trim() !== '')
);
}
export function resourceDocumentPreviewMarkdown(
resource: ProjectResource,
content: string,
) {
if (projectResourceCardPreviewKind(resource) !== 'code') {
return content;
}
const language =
CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? 'text';
// 围栏长于源码里的任意反引号串,代码生成模板中的 Markdown 不能提前闭合代码块。
const longestRun = (content.match(/`+/g) ?? []).reduce(
(length, run) => Math.max(length, run.length),
0,
);
const fence = '`'.repeat(Math.max(3, longestRun + 1));
return `${fence}${language}\n${content}${content.endsWith('\n') ? '' : '\n'}${fence}`;
}
+7 -21
View File
@@ -2913,7 +2913,6 @@ textarea {
border: 1px solid #b9d8c5;
border-radius: 8px;
background: #f1f8f3;
color: #1f3d2a;
overflow: hidden;
}
@@ -2966,7 +2965,7 @@ textarea {
.project-supervisor-process-elapsed {
margin-left: auto !important;
color: inherit;
font-size: 13px !important;
font-size: inherit;
font-style: normal;
font-weight: 500;
white-space: nowrap;
@@ -2991,8 +2990,8 @@ textarea {
.project-supervisor-process-card p {
margin: 0;
color: #263142;
font-size: 13px;
color: inherit;
font-size: inherit;
line-height: 1.55;
display: -webkit-box;
-webkit-box-orient: vertical;
@@ -3037,11 +3036,6 @@ textarea {
.game-workbench-chat .project-supervisor-process-card {
border-color: var(--platform-surface-border);
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
.game-workbench-chat .project-supervisor-process-card p {
color: var(--platform-text-base);
}
.project-supervisor-surface .agent-runtime-status {
@@ -11648,8 +11642,6 @@ button.design-workspace-tree__entry:hover,
.design-agent-reasoning {
margin: 8px 0;
color: var(--text-muted);
font-size: 0.82em;
}
.design-agent-reasoning summary {
cursor: pointer;
@@ -11658,7 +11650,6 @@ button.design-workspace-tree__entry:hover,
margin: 6px 0 0;
white-space: pre-wrap;
font: inherit;
opacity: 0.8;
}
/* ============================================================
@@ -11807,7 +11798,6 @@ button.design-workspace-tree__entry:hover,
> .project-supervisor-process-card {
border-color: var(--platform-line-soft);
background: var(--platform-neutral-bg);
color: var(--platform-text-base);
}
.game-workbench-chat
@@ -12021,7 +12011,6 @@ button.design-workspace-tree__entry:hover,
overflow: hidden;
border-radius: 12px;
background: var(--platform-button-secondary-fill);
color: var(--platform-text-base);
}
/* 块头:点整行展开,浅底条目本身是块头。 */
@@ -12209,7 +12198,7 @@ button.design-workspace-tree__entry:hover,
border: 1px solid var(--platform-line-soft);
border-radius: 8px;
background: var(--platform-neutral-bg);
color: var(--platform-text-base);
color: inherit;
font-size: 11px;
line-height: 1.6;
white-space: pre-wrap;
@@ -12229,7 +12218,7 @@ button.design-workspace-tree__entry:hover,
align-items: baseline;
gap: 6px;
min-width: 0;
color: var(--platform-text-base);
color: inherit;
font-size: 12px;
overflow-wrap: anywhere;
}
@@ -12295,7 +12284,6 @@ button.design-workspace-tree__entry:hover,
.project-supervisor-conversation
> .project-supervisor-process-card {
margin: 0 16px 8px;
color: var(--platform-text-base);
}
/* 直连回合的思考过程默认折叠<details> 默认收起展开后按步骤列过程行
@@ -12389,7 +12377,7 @@ button.design-workspace-tree__entry:hover,
.message-turn-process {
min-width: 0;
color: var(--platform-text-base);
color: var(--platform-text-soft);
}
.message-turn-process > summary {
@@ -12426,8 +12414,6 @@ button.design-workspace-tree__entry:hover,
.project-supervisor-message-list
details[data-testid='live-reasoning'] {
margin: 0;
color: var(--platform-text-base);
font-size: 12px;
}
.game-workbench-chat
@@ -12445,7 +12431,7 @@ button.design-workspace-tree__entry:hover,
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: inherit;
font-size: 12px;
font-size: inherit;
line-height: 1.5;
}
/* Markdown 正文继承消息颜色,层级由字号和字重表达,不混入全局段落或引用颜色。 */
@@ -0,0 +1,87 @@
import { X } from 'lucide-react';
import { useEffect, useMemo } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformIconButton } from '../../../../../packages/shared/src/components/PlatformIconButton';
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceDocumentPreviewMarkdown } from '../../features/resource-canvas/resourceDocumentPreviewModel';
import type { ProjectResourceCardPreviewState } from './resourceCardPreviewModel';
import type { ProjectResource } from './resourceProjectionModel';
export function ResourceDocumentPreviewDialog({
resource,
identity,
preview,
onRequestPreview,
onClose,
}: {
resource: ProjectResource;
identity: string;
preview: ProjectResourceCardPreviewState;
onRequestPreview: (
resource: ProjectResource,
identity: string,
reason: 'detail',
) => void;
onClose: () => void;
}) {
useEffect(() => {
onRequestPreview(resource, identity, 'detail');
}, [resource, identity, onRequestPreview]);
const content =
preview.status === 'loaded' ? preview.preview.content : undefined;
const markdown = useMemo(
() =>
content === undefined
? ''
: resourceDocumentPreviewMarkdown(resource, content),
[resource, content],
);
return (
<ThemedModal
open
ariaLabel="文档预览"
onClose={onClose}
panelClassName="game-resource-document-preview"
>
<header className="game-resource-document-preview__header">
<strong title={resource.label}>{resource.label}</strong>
<PlatformIconButton
label="关闭文档预览"
title="关闭"
icon={<X size={18} aria-hidden="true" />}
onClick={onClose}
/>
</header>
<div className="game-resource-document-preview__body">
{preview.status === 'idle' || preview.status === 'loading' ? (
<p role="status"></p>
) : preview.status === 'failed' ? (
<>
<p role="alert">{preview.error}</p>
{preview.retryable ? (
<PlatformActionButton
tone="secondary"
onClick={() => onRequestPreview(resource, identity, 'detail')}
>
</PlatformActionButton>
) : null}
</>
) : content === undefined ? (
<p role="alert"></p>
) : content === '' ? (
<p role="status"></p>
) : (
<ChatMarkdownMessage
role="assistant"
text={markdown}
preserveBlankLines
/>
)}
</div>
</ThemedModal>
);
}
@@ -18,6 +18,7 @@ import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import {
AtSign,
Crosshair,
Eye,
FileCode2,
FileText,
FolderOpen,
@@ -190,6 +191,7 @@ import {
currentVersionResourceBindingIds,
isResourceUsedByCurrentVersion,
} from '../../features/resource-canvas/resourceCanvasVersionBindingModel';
import { isResourceDocumentPreviewable } from '../../features/resource-canvas/resourceDocumentPreviewModel';
import { ResourcePromptPolishSlot } from '../../features/resource-canvas/ResourcePromptPolishSlot';
import {
type LocalProjectVersionReplacementCandidate,
@@ -308,6 +310,7 @@ import {
ResourceDependencyOverlay,
type ResourceDependencyOverlayHandle,
} from './ResourceDependencyOverlay';
import { ResourceDocumentPreviewDialog } from './ResourceDocumentPreviewDialog';
import {
canonicalProjectedResourceMediaType,
createResourceEditRequestIdentity,
@@ -1582,6 +1585,8 @@ export default function ProjectDevelopmentView({
createResourceCanvasHistory,
);
const [resourcePanelOpen, setResourcePanelOpen] = useState(false);
const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] =
useState<string | null>(null);
/**
*
*
@@ -1939,6 +1944,7 @@ export default function ProjectDevelopmentView({
isClassificationPanelOpen: resourceClassificationOverlayOpen,
isRenameDialogOpen: resourceRenameAssetId !== null,
isRecoveryPanelOpen: resourceRecoveryPanelOpen,
isDocumentPreviewOpen: resourceDocumentPreviewIdentity !== null,
},
}),
boundaryRefs: [resourceBookManagerRef],
@@ -1966,6 +1972,7 @@ export default function ProjectDevelopmentView({
isClassificationPanelOpen: resourceClassificationOverlayOpen,
isRenameDialogOpen: resourceRenameAssetId !== null,
isRecoveryPanelOpen: resourceRecoveryPanelOpen,
isDocumentPreviewOpen: resourceDocumentPreviewIdentity !== null,
},
})
) {
@@ -1987,6 +1994,7 @@ export default function ProjectDevelopmentView({
resourceClassificationOverlayOpen,
resourcePanelOpen,
resourceRecoveryPanelOpen,
resourceDocumentPreviewIdentity,
resourceRenameAssetId,
selectedResourceIds,
uiEditorRoute,
@@ -6350,6 +6358,27 @@ export default function ProjectDevelopmentView({
null)
: null;
useEffect(() => {
if (
mode !== 'resources' ||
uiEditorRoute ||
resourceDocumentPreviewIdentity !== selectedResourcePreviewIdentity
) {
setResourceDocumentPreviewIdentity(null);
}
}, [
mode,
uiEditorRoute,
resourceDocumentPreviewIdentity,
selectedResourcePreviewIdentity,
]);
useEffect(() => {
if (!resourceDocumentPreviewIdentity) return;
protectResourceCardPreview(resourceDocumentPreviewIdentity);
return () => protectResourceCardPreview(null);
}, [protectResourceCardPreview, resourceDocumentPreviewIdentity]);
/**
* revision
*
@@ -7863,13 +7892,38 @@ export default function ProjectDevelopmentView({
selectedToolbarStyle &&
((selectedToolbarActions?.size ?? 0) > 0 ||
Boolean(selectedResource?.manifestAssetId) ||
Boolean(
selectedResource &&
isResourceDocumentPreviewable(selectedResource),
) ||
selectedResourceOpensUiEditor) ? (
<ImageCanvasSelectedLayerToolbarView
selectedLayer={selectedResourceLayer}
selectedToolbarStyle={selectedToolbarStyle}
supportedActions={selectedToolbarActions}
downloadLabel="导出"
extraActions={
<>
{selectedResource &&
selectedResourcePreviewIdentity &&
isResourceDocumentPreviewable(
selectedResource,
) ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="预览"
title="预览"
icon={<Eye className="h-4 w-4" />}
onClick={() => {
stopActiveCardMedia();
setResourceDocumentPreviewIdentity(
selectedResourcePreviewIdentity,
);
}}
>
<span></span>
</CanvasChromeButton>
) : null}
{selectedResourceOpensUiEditor ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -7998,49 +8052,36 @@ export default function ProjectDevelopmentView({
<span></span>
</CanvasChromeButton>
) : null}
{/*
线
`image-canvas-editor__floating-toolbar-divider`
*/}
{selectedResource?.manifestAssetId ? (
<>
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="删除素材"
title="删除素材"
icon={<Trash2 className="h-4 w-4" />}
disabled={
resourceAssetDeleteFlow.deleting ||
resourceAssetDeleteFlow.preparing
}
onClick={() => {
if (!selectedResource.manifestAssetId) {
return;
}
void resourceAssetDeleteFlow.requestDelete(
{
assetId:
selectedResource.manifestAssetId,
// 资源投影的 `path` 就是 manifest 资产的
// `localPath`(见 `resourceProjectionModel`),
// 与面板曾用的副标题同源。
localPath: selectedResource.path,
},
);
}}
>
<span></span>
</CanvasChromeButton>
</>
) : null}
</>
}
endActions={
selectedResource?.manifestAssetId ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="删除素材"
title="删除素材"
icon={<Trash2 className="h-4 w-4" />}
disabled={
resourceAssetDeleteFlow.deleting ||
resourceAssetDeleteFlow.preparing
}
onClick={() => {
if (!selectedResource.manifestAssetId) {
return;
}
void resourceAssetDeleteFlow.requestDelete({
assetId: selectedResource.manifestAssetId,
// 资源投影的 `path` 就是 manifest 资产的
// `localPath`(见 `resourceProjectionModel`),
// 与面板曾用的副标题同源。
localPath: selectedResource.path,
});
}}
>
<span></span>
</CanvasChromeButton>
) : null
}
onOpenQuickEditPanel={openResourceQuickEditPanel}
onOpenRedrawPanel={() => {}}
onOpenCropExpandPanel={() => {}}
@@ -8765,6 +8806,24 @@ export default function ProjectDevelopmentView({
onFocusTask={focusResourceAssetGenerationTask}
/>
{mode === 'resources' &&
!uiEditorRoute &&
selectedResource &&
resourceDocumentPreviewIdentity &&
resourceDocumentPreviewIdentity === selectedResourcePreviewIdentity ? (
<ResourceDocumentPreviewDialog
key={resourceDocumentPreviewIdentity}
resource={selectedResource}
identity={resourceDocumentPreviewIdentity}
preview={
resourceCardPreviews.previews.get(
resourceDocumentPreviewIdentity,
) ?? resourceCardPreviews.idlePreview
}
onRequestPreview={resourceCardPreviews.requestPreview}
onClose={() => setResourceDocumentPreviewIdentity(null)}
/>
) : null}
{resourcePanelOpen ? (
<ResourceCanvasPanelView
entries={resourcePanelEntries}
@@ -224,7 +224,8 @@ const markdownExtension = /\.(md|markdown|mdx)$/iu;
const cardCodeExtension =
/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|json|jsonc|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu;
/** 纯文本:按纯文本处理,显示前几行但不做标记清理。 */
const plainTextExtension = /\.(txt|text|csv|tsv|log|ini|conf|cfg|properties|env)$/iu;
const plainTextExtension =
/\.(txt|text|csv|tsv|log|ini|conf|cfg|properties|env)$/iu;
/**
* 取路径末段的扩展名(小写、不含点)。取不到(无扩展名)时返回 `null`。
@@ -330,7 +331,7 @@ export function projectResourceCardPreviewVariant(
}
/**
* 代码卡**不做文件读取**的判据:卡面只用路径画图标,读内容既无用又占读取槽
* 卡面是否预取正文:代码卡只用路径画图标,不为卡面占读取槽;显式详情请求单独放行
*
* 与 `projectResourceCardPreviewVariant` 同源,避免"卡面不画内容、却仍在后台读内容"的分叉。
*/
@@ -26,8 +26,8 @@ import {
projectResourceCardPreviewImageDimensions,
type ProjectResourceCardPreviewKind,
projectResourceCardPreviewKind,
projectResourceCardPreviewReadsContent,
type ProjectResourceCardPreviewPayload,
projectResourceCardPreviewReadsContent,
type ProjectResourceCardPreviewState,
type ProjectResourceCardPreviewTransportPayload,
projectResourceMediaPreviewCategory,
@@ -492,11 +492,7 @@ export function useProjectResourceCardPreviews(input: {
job: PreviewJob,
): Promise<ProjectResourceCardPreviewTransportPayload> => {
const kind = projectResourceCardPreviewKind(job.resource);
/**
* 这段之前的 `kind === 'document' || kind === 'code'` 双分支已收窄:代码卡在
* `requestPreview` 的入队门禁就被挡下(**判据同源**,见 `projectResourceCardPreviewReadsContent`),
* 走到这里的只可能是需要读内容的文档卡(Markdown / 纯文本)。
*/
// 内联文档和按需打开的代码详情共用现有预览结果,不另建读取或缓存链路。
if (job.resource.content !== undefined) {
return {
path: job.resource.path,
@@ -508,7 +504,7 @@ export function useProjectResourceCardPreviews(input: {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
throw new Error(
kind === 'document'
kind === 'document' || kind === 'code'
? '文档预览需要在客户端内打开'
: '媒体预览需要在客户端内打开',
);
@@ -636,16 +632,11 @@ export function useProjectResourceCardPreviews(input: {
return;
}
const kind = projectResourceCardPreviewKind(resource);
/**
* 代码卡**不入队列、不读内容**:卡面只用路径画图标 + 类型标签,读回来的内容没有任何
* 消费方。此前代码卡与文档卡走同一条读取分支,读回一大段源码再被折成一行摘要 ——
* 既是"丑预览"的来源,也白占全局 3 个读取槽。
*
* 判据与卡面分流(`projectResourceCardPreviewVariant`)同源,避免"卡面不画内容、
* 后台仍在读内容"的分叉。这里直接 `return` 而不是入队后失败:卡停在 `idle`,
* 卡面照常画代码图标,不产生误导性的失败态。
*/
if (!projectResourceCardPreviewReadsContent(resource)) {
// 代码卡不预取正文;只有显式打开详情才占读取槽,沿用同一队列和项目权限边界。
if (
!projectResourceCardPreviewReadsContent(resource) &&
reason !== 'detail'
) {
return;
}
if (
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { AgentMessageContent } from '../../../packages/shared/src/components/AgentMessageContent';
import { ChatMarkdownMessage } from '../src/components/ChatMarkdownMessage';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const sharedCss = readFileSync(
resolve(
process.cwd(),
'packages/shared/src/components/AgentMessageContent.css',
),
'utf8',
);
const appCss = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const base = '.agent-message-content[data-agent-content]';
const processTone = ".agent-message-content[data-agent-content='process']";
describe('AgentMessageContent', () => {
it('正文与过程复用表现组件,保留折叠语义和宿主属性', () => {
const { container } = render(
<>
<AgentMessageContent></AgentMessageContent>
<AgentMessageContent as="details" tone="process" aria-label="思考过程">
<summary></summary>
<pre></pre>
</AgentMessageContent>
</>,
);
expect(
container.querySelector('[data-agent-content="body"]')?.textContent,
).toBe('最终回复');
const details = container.querySelector('details')!;
expect(details.getAttribute('data-agent-content')).toBe('process');
expect(details.getAttribute('aria-label')).toBe('思考过程');
expect(details.open).toBe(false);
});
it('过程的标题和表格继承层级变量,不用强制字号压回正文大小', () => {
const { container } = render(
<AgentMessageContent tone="process">
<ChatMarkdownMessage
role="assistant"
text={
'# 分析\n\n| 项目 |\n| --- |\n| 内容 |\n\n```js\nconst value = 1;\n```'
}
/>
</AgentMessageContent>,
);
expect(container.querySelector('h1')?.getAttribute('style')).toContain(
'--agent-message-heading-size',
);
expect(container.querySelector('table')?.getAttribute('style')).toContain(
'--agent-message-table-size',
);
expect(container.querySelector('pre code .hljs-keyword')).not.toBeNull();
});
it.each([sharedCss + appCss, appCss + sharedCss])(
'共享层级不受宿主 CSS 加载顺序影响',
(css) => {
const rules = parseStyleSheet(css);
const body = resolveDeclarations(rules, [base], 1024);
expect(declaration(body, 'font-size')).toBe('14px');
expect(declaration(body, 'color')).toContain('--platform-text-strong');
for (const host of [
['.design-agent-reasoning'],
['.agent-tool-call-group'],
[
'.project-supervisor-process-card',
'.game-workbench-chat .project-supervisor-process-card',
'.game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-conversation > .project-supervisor-process-card',
],
]) {
const process = resolveDeclarations(
rules,
[base, processTone, ...host],
1024,
);
expect(declaration(process, 'font-size')).toBe('12px');
expect(declaration(process, 'color')).toContain('--platform-text-soft');
expect(declaration(process, '--agent-message-heading-size')).toBe(
'1em',
);
}
const failure = resolveDeclarations(
rules,
[
'.agent-tool-call-row-status',
".agent-tool-call-group-row[data-status='failed'] .agent-tool-call-row-status",
],
1024,
);
expect(declaration(failure, 'color')).toBe(
'var(--platform-button-danger-text)',
);
},
);
});
@@ -16,6 +16,46 @@ function FailingChild({ shouldThrow }: { shouldThrow: boolean }) {
}
describe('ChatMarkdownMessage', () => {
it('代码块带语言标识及高亮节点,文件预览保留空行和 HTML 源码', () => {
const source =
'const value = "<script>alert(1)</script>";\n\n\n return value;\n';
const { container } = render(
<ChatMarkdownMessage
role="assistant"
preserveBlankLines
text={`\`\`\`\`typescript\n${source}\`\`\`\``}
/>,
);
expect(container.querySelector('pre code')?.textContent).toBe(source);
expect(
container.querySelector('code.language-typescript .hljs-keyword'),
).not.toBeNull();
expect(container.querySelector('script')).toBeNull();
});
it('未知语言保留代码而不进入错误回退', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'```unknown-language\n原始内容\n```'}
/>,
);
expect(container.querySelector('pre code')?.textContent).toBe('原始内容\n');
});
it('大文件跳过高亮但保留完整代码', () => {
const source = 'const value = 42;\n'.repeat(6000);
const { container } = render(
<ChatMarkdownMessage
role="assistant"
preserveBlankLines
text={`\`\`\`js\n${source}\`\`\``}
/>,
);
expect(container.querySelector('pre code')?.textContent).toBe(source);
expect(container.querySelector('.hljs-keyword')).toBeNull();
});
it('渲染 assistant 的 GFM 内容与代码块', () => {
const { container } = render(
<ChatMarkdownMessage
@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ResourceDocumentPreviewDialog } from '../src/view/project-development/ResourceDocumentPreviewDialog';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
vi.mock('../src/components/modal/ThemedModal', () => ({
ThemedModal: ({
children,
ariaLabel,
}: {
children: ReactNode;
ariaLabel: string;
}) => (
<section role="dialog" aria-label={ariaLabel}>
{children}
</section>
),
}));
afterEach(cleanup);
const resource: ProjectResource = {
id: 'doc',
path: 'docs/design.md',
label: '设计文档',
mediaType: 'text/markdown',
category: 'document',
subtype: 'document',
manifestAssetId: 'doc',
sourceLabel: '',
taskTitle: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
};
describe('ResourceDocumentPreviewDialog', () => {
it('加载时通过现有详情回调请求正文,加载完成复用 Markdown 渲染', () => {
const onRequestPreview = vi.fn();
const onClose = vi.fn();
const props = { resource, identity: 'doc:v1', onRequestPreview, onClose };
const { rerender } = render(
<ResourceDocumentPreviewDialog
{...props}
preview={{ status: 'loading' }}
/>,
);
expect(screen.getByRole('status').textContent).toContain('加载');
expect(onRequestPreview).toHaveBeenCalledWith(resource, 'doc:v1', 'detail');
rerender(
<ResourceDocumentPreviewDialog
{...props}
preview={{
status: 'loaded',
preview: {
path: resource.path,
mediaType: resource.mediaType,
byteLen: 10,
content: '# 标题\n\n**正文**',
},
}}
/>,
);
expect(screen.getByRole('heading', { name: '标题' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭文档预览' }));
expect(onClose).toHaveBeenCalledOnce();
});
it('失败显示错误与重试,换资源进入加载态时不残留旧正文', () => {
const onRequestPreview = vi.fn();
const props = {
resource,
identity: 'doc:v1',
onRequestPreview,
onClose: vi.fn(),
};
const { rerender } = render(
<ResourceDocumentPreviewDialog
{...props}
preview={{
status: 'failed',
error: '文档读取失败',
retryable: true,
}}
/>,
);
expect(screen.getByRole('alert').textContent).toBe('文档读取失败');
onRequestPreview.mockClear();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(onRequestPreview).toHaveBeenCalledWith(resource, 'doc:v1', 'detail');
rerender(
<ResourceDocumentPreviewDialog
{...props}
identity="doc:v2"
preview={{ status: 'loading' }}
/>,
);
expect(screen.queryByRole('alert')).toBeNull();
expect(screen.getByRole('status').textContent).toContain('加载');
});
it('空文档显示空态而不是永久加载', () => {
render(
<ResourceDocumentPreviewDialog
resource={resource}
identity="doc:v1"
onRequestPreview={vi.fn()}
onClose={vi.fn()}
preview={{
status: 'loaded',
preview: {
path: resource.path,
mediaType: resource.mediaType,
byteLen: 0,
content: '',
},
}}
/>,
);
expect(screen.getByRole('status').textContent).toBe('文档为空');
});
});
@@ -227,6 +227,7 @@ export function registerDesignAgentSurfaceTests() {
const summary = await screen.findByText('思考过程');
const details = summary.closest('details') as HTMLDetailsElement;
expect(details.getAttribute('data-agent-content')).toBe('process');
expect(details.open).toBe(false);
fireEvent.click(summary);
expect(details.open).toBe(true);
@@ -257,6 +258,11 @@ export function registerDesignAgentSurfaceTests() {
(summary) => summary.closest('details') as HTMLDetailsElement,
);
expect(details.every((element) => !element.open)).toBe(true);
expect(
details.every(
(element) => element.getAttribute('data-agent-content') === 'process',
),
).toBe(true);
fireEvent.click(summaries[0]);
expect(details[0].open).toBe(true);
expect(details[1].open).toBe(false);
@@ -112,6 +112,7 @@ export function registerToolCallGroupTests() {
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
expect(group).not.toBeNull();
expect(group.getAttribute('data-agent-content')).toBe('process');
const head = within(group).getByTestId('agent-tool-call-group-head');
// 块头是一行按钮:图标 + 汇总 + 展开箭头,默认折叠,正文由 `hidden` 收起。
expect(head.tagName).toBe('BUTTON');
@@ -240,6 +240,20 @@ describe('resourceCanvasFocusModel', () => {
hostOverlay: { ...closed, isRenameDialogOpen: true },
}),
).toBe(false);
const documentPreviewOverlay = { ...closed, isDocumentPreviewOpen: true };
expect(
resolveResourceCanvasFocusEscapeActive({
...base,
hostOverlay: documentPreviewOverlay,
}),
).toBe(false);
expect(
resolveResourceCanvasFloatingPanelDismissOpen({
isCanvasVisible: true,
isFloatingPanelOpen: true,
hostOverlay: documentPreviewOverlay,
}),
).toBe(false);
});
});
@@ -237,6 +237,34 @@ describe('resource canvas toolbar actions', () => {
);
});
it.each([
['image/png', 'character', 'assets/hero.png'],
['image/svg+xml', 'icon', 'assets/icon.svg'],
['image/png', 'character-animation', 'assets/walk.png'],
['video/mp4', 'video', 'assets/intro.mp4'],
['audio/wav', 'sound-effect', 'assets/hit.wav'],
['audio/mpeg', 'background-music', 'assets/music.mp3'],
['text/html', 'ui-prototype', 'ui/menu.html'],
['text/markdown', 'document', 'docs/design.md'],
['application/json', 'other', 'assets/data.json'],
['application/octet-stream', 'other', 'assets/model.bin'],
])(
'%s 素材导出不依赖媒体类型、预览或 manifest 身份',
(mediaType, subtype, path) => {
expect(
toolbarActions(
createManifest(),
createResource({
mediaType,
subtype,
path,
manifestAssetId: null,
}),
),
).toContain('download');
},
);
it('拿不到本地文件路径的资源不放行下载,避免渲染出点了报错的按钮', () => {
const manifest = createManifest();
expect(
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import {
isResourceDocumentPreviewable,
resourceDocumentPreviewMarkdown,
} from '../src/features/resource-canvas/resourceDocumentPreviewModel';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
function resource(path: string): ProjectResource {
return {
id: path,
path,
label: path,
mediaType: 'text/plain',
category: 'document',
subtype: 'document',
manifestAssetId: 'doc',
sourceLabel: '',
taskTitle: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
};
}
describe('resourceDocumentPreviewMarkdown', () => {
it('文档与空行原样进入 Markdown 渲染', () => {
const text = '# 标题\n\n\n|列|\n|-|\n|值|';
expect(
resourceDocumentPreviewMarkdown(resource('docs/design.md'), text),
).toBe(text);
expect(isResourceDocumentPreviewable(resource('docs/design.md'))).toBe(
true,
);
});
it.each([
['game/main.ts', 'typescript'],
['game/main.js', 'javascript'],
['assets/data.json', 'json'],
['game/main.py', 'python'],
])('%s 包为 %s 代码块', (path, language) => {
expect(
resourceDocumentPreviewMarkdown(resource(path), ' source\n\n\n'),
).toBe(`\`\`\`${language}\n source\n\n\n\`\`\``);
});
it('正文包含 Markdown 围栏时不会逃出代码块', () => {
const text = 'const sample = "````";\n\n\n<script>alert(1)</script>';
expect(
resourceDocumentPreviewMarkdown(resource('game/main.js'), text),
).toBe(`\`\`\`\`\`javascript\n${text}\n\`\`\`\`\``);
});
it('图片没有文档预览入口,内联文档不要求文件路径', () => {
expect(
isResourceDocumentPreviewable({
...resource('assets/image.png'),
mediaType: 'image/png',
subtype: 'image',
}),
).toBe(false);
expect(
isResourceDocumentPreviewable({
...resource(''),
content: '# 回执',
subtype: 'agent-result',
}),
).toBe(true);
expect(isResourceDocumentPreviewable(resource(''))).toBe(false);
});
});
@@ -141,6 +141,65 @@ afterEach(() => {
});
describe('useProjectResourceCardPreviews', () => {
it('代码卡不预取正文,显式详情复用文本预览队列与缓存', async () => {
const code = resource('code', {
path: 'game/main.ts',
subtype: 'code',
mediaType: 'text/typescript',
});
const invoke = vi.fn(
async (_command: string, _args?: Record<string, unknown>) => ({
path: code.path,
mediaType: code.mediaType,
byteLen: 19,
content: 'const answer = 42;',
}),
);
window.__TAURI__ = {
core: {
invoke: async <Result>(
command: string,
args?: Record<string, unknown>,
) => (await invoke(command, args)) as Result,
},
};
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/document-preview',
projectId: 'document-preview',
mode: 'dependency',
resources: [code],
canvasRef: { current: document.createElement('div') },
eagerPreviewLimit: 12,
}),
);
const identity = result.current.identityByResourceId.get(code.id)!;
act(() => result.current.requestPreview(code, identity, 'visible'));
expect(
invoke.mock.calls.some(
([command]) => command === 'read_local_project_text_preview',
),
).toBe(false);
act(() => result.current.requestPreview(code, identity, 'detail'));
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(invoke).toHaveBeenCalledWith(
'read_local_project_text_preview',
expect.objectContaining({
relativePath: code.path,
scopeId: expect.any(String),
requestId: expect.any(String),
}),
);
act(() => result.current.requestPreview(code, identity, 'detail'));
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_text_preview',
),
).toHaveLength(1);
});
it('prefetches initial previewable resources without requiring a detail click', async () => {
const art = resource('initial-art');
const invoke = vi.fn(

Some files were not shown because too many files have changed in this diff Show More