重构策划 Agent 工作台界面
Project CI / Frontend tests (pull_request) Failing after 3m19s
Project CI / Repository checks (pull_request) Failing after 3m25s
Project CI / Native shell tests (pull_request) Failing after 6m54s
Project CI / Backend tests (pull_request) Successful in 7m20s

新增始终可见的策划工作区与文件预览

复用 GameAgent 双栏布局并调整审批澄清交互

审批按钮改为文字与图标并保留现有 Runtime 行为
This commit is contained in:
2026-09-11 03:34:56 +00:00
parent 7484764024
commit 9345236b93
5 changed files with 746 additions and 89 deletions
@@ -1,12 +1,11 @@
import { Check, RotateCcw, X } from 'lucide-react';
import { useState } from 'react';
import { closeDialogOnEscape } from '../../app/dialogs';
import type {
DesignClarificationRequest,
DesignView,
DesignWorkspaceEntry,
} from '../../app/types';
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
const PHASE_LABELS: Record<string, string> = {
concept: '概念设计',
@@ -37,72 +36,71 @@ type DesignAgentSurfaceProps = {
export function DesignAgentSurface({
view,
files,
previewPath,
previewText,
busy,
error,
onApprove,
onClarify,
onRetry,
onOpenFile,
onClosePreview,
}: DesignAgentSurfaceProps) {
const [clarifyText, setClarifyText] = useState('');
const phase = view?.session.currentPhase ?? 'concept';
const pending = view?.session.pendingApproval;
const clarification = view?.session.pendingClarification;
return (
<section
className="plan-gdd-surface design-agent-surface"
aria-label="策划"
>
<div className="plan-gdd-stage-progress">
<div className="plan-gdd-stage-progress__header">
<section className="design-agent-controls" aria-label="策划阶段控制">
<div className="design-agent-controls__header">
<div>
<span></span>
<strong>{PHASE_LABELS[phase] ?? phase}</strong>
{view?.running ? <span></span> : null}
</div>
{view?.running ? (
<span className="design-agent-controls__status"></span>
) : null}
</div>
{error ? (
<p className="plan-gdd-stage-progress__delivery-error">{error}</p>
) : null}
{error ? <p className="design-agent-controls__error">{error}</p> : null}
{pending ? (
<div className="design-agent-approval">
<button
type="button"
disabled={busy}
onClick={() => onApprove(pending.requestId, true)}
aria-label="批准"
>
</button>
<button
type="button"
disabled={busy}
onClick={() => onApprove(pending.requestId, false)}
aria-label="不批准"
>
</button>
<p></p>
<div>
<button
type="button"
disabled={busy}
onClick={() => onApprove(pending.requestId, true)}
>
<Check size={15} aria-hidden="true" />
</button>
<button
type="button"
disabled={busy}
onClick={() => onApprove(pending.requestId, false)}
>
<X size={15} aria-hidden="true" />
</button>
</div>
</div>
) : null}
{clarification ? (
<div className="design-agent-clarify">
<p>{clarification.question}</p>
{clarification.options.map((option, index) => (
<button
key={`${clarification.requestId}-${index}`}
type="button"
disabled={busy}
onClick={() => onClarify(clarification, index, clarifyText)}
>
{option}
</button>
))}
<div className="design-agent-clarify__options">
{clarification.options.map((option, index) => (
<button
key={`${clarification.requestId}-${index}`}
type="button"
disabled={busy}
onClick={() => onClarify(clarification, index, clarifyText)}
>
{option}
</button>
))}
</div>
<textarea
rows={2}
value={clarifyText}
disabled={busy}
placeholder="补充说明(可选)"
onChange={(event) => setClarifyText(event.currentTarget.value)}
/>
{clarification.options.length === 0 ? (
@@ -111,57 +109,21 @@ export function DesignAgentSurface({
disabled={busy || !clarifyText.trim()}
onClick={() => onClarify(clarification, null, clarifyText)}
>
</button>
) : null}
</div>
) : null}
{view?.canRetry ? (
<div className="design-agent-approval">
<button type="button" disabled={busy} onClick={onRetry}>
</button>
</div>
) : null}
<div className="design-agent-files">
{files.map((file) =>
file.kind === 'directory' ? (
<div key={file.path} className="design-agent-file is-dir">
{file.path}
</div>
) : (
<button
key={file.path}
type="button"
className="design-agent-file"
onClick={() => onOpenFile(file.path)}
>
{file.path}
</button>
),
)}
</div>
{previewPath ? (
<div
className="design-agent-preview"
role="dialog"
aria-label={previewPath}
onKeyDown={(event) => closeDialogOnEscape(event, onClosePreview)}
<button
className="design-agent-retry"
type="button"
disabled={busy}
onClick={onRetry}
>
<header>
<strong>{previewPath}</strong>
<button type="button" onClick={onClosePreview} aria-label="关闭">
×
</button>
</header>
<div className="design-agent-preview__body">
{previewPath.endsWith('.md') ? (
<ChatMarkdownMessage role="assistant" text={previewText} />
) : (
<pre>{previewText}</pre>
)}
</div>
</div>
<RotateCcw size={15} aria-hidden="true" />
</button>
) : null}
</section>
);
@@ -0,0 +1,284 @@
import { ArrowLeft, Check, File, Folder, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { DesignView, DesignWorkspaceEntry } from '../../app/types';
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
const PHASES = [
['concept', '概念设计'],
['top_design', '顶层设计'],
['architecture', '系统架构'],
['systems', '系统文档'],
['tdd', '技术文档'],
] as const;
type DesignWorkspacePanelProps = {
projectPath: string;
};
function phaseLabel(phase: string) {
return PHASES.find(([id]) => id === phase)?.[1] ?? phase;
}
function pathDepth(path: string) {
return path.split(/[\\/]/u).filter(Boolean).length - 1;
}
export function DesignWorkspacePanel({
projectPath,
}: DesignWorkspacePanelProps) {
const [view, setView] = useState<DesignView | null>(null);
const [files, setFiles] = useState<DesignWorkspaceEntry[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [previewText, setPreviewText] = useState('');
const [loading, setLoading] = useState(true);
const [previewLoading, setPreviewLoading] = useState(false);
const [error, setError] = useState('');
const loadWorkspace = useCallback(async () => {
const invoke = resolveTauriInvoke();
if (!invoke || !projectPath.trim()) {
setLoading(false);
return;
}
setLoading(true);
setError('');
try {
const [nextView, nextFiles] = await Promise.all([
invoke<DesignView | null>('hydrate_design_agent_session', {
projectPath,
}),
invoke<DesignWorkspaceEntry[]>('list_design_workspace', {
projectPath,
}),
]);
setView(nextView);
setFiles(nextFiles);
setSelectedPath((current) =>
current &&
nextFiles.some(
(entry) => entry.kind !== 'directory' && entry.path === current,
)
? current
: null,
);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
setFiles([]);
} finally {
setLoading(false);
}
}, [projectPath]);
useEffect(() => {
void loadWorkspace();
}, [loadWorkspace]);
useEffect(() => {
const listen = window.__TAURI__?.event?.listen;
if (!listen || !projectPath.trim()) {
return;
}
let disposed = false;
let cleanup: (() => void) | null = null;
void listen<{ projectPath: string; kind: string }>(
'design-agent-update',
(event) => {
if (!disposed && event.payload.projectPath === projectPath) {
void loadWorkspace();
}
},
)
.then((unlisten) => {
if (disposed) {
unlisten();
} else {
cleanup = unlisten;
}
})
.catch(() => undefined);
return () => {
disposed = true;
cleanup?.();
};
}, [loadWorkspace, projectPath]);
const sortedFiles = useMemo(
() =>
[...files].sort((left, right) => {
if (left.kind !== right.kind) {
return left.kind === 'directory' ? -1 : 1;
}
return left.path.localeCompare(right.path, 'zh-CN');
}),
[files],
);
const openFile = useCallback(
async (path: string) => {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
setSelectedPath(path);
setPreviewLoading(true);
setError('');
try {
const content = await invoke<string>('read_design_workspace_file', {
projectPath,
path,
});
setPreviewText(content);
} catch (nextError) {
setPreviewText('');
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
} finally {
setPreviewLoading(false);
}
},
[projectPath],
);
const currentPhase = view?.session.currentPhase ?? 'concept';
const currentPhaseIndex = Math.max(
0,
PHASES.findIndex(([id]) => id === currentPhase),
);
const selectedEntry = selectedPath
? files.find((entry) => entry.path === selectedPath)
: null;
return (
<section className="design-workspace-panel" aria-label="策划工作区">
<header className="design-workspace-panel__header">
<div>
<span className="design-workspace-panel__eyebrow"></span>
<h1>{phaseLabel(currentPhase)}</h1>
<p>Agent </p>
</div>
<button
type="button"
className="design-workspace-panel__refresh"
onClick={() => void loadWorkspace()}
disabled={loading}
aria-label="刷新策划工作区"
>
<RefreshCw size={15} aria-hidden="true" />
</button>
</header>
<nav className="design-phase-rail" aria-label="策划阶段">
{PHASES.map(([id, label], index) => (
<div
key={id}
className={`design-phase-rail__item${
index < currentPhaseIndex ? ' is-complete' : ''
}${index === currentPhaseIndex ? ' is-current' : ''}`}
>
<span className="design-phase-rail__marker">
{index < currentPhaseIndex ? (
<Check size={12} aria-hidden="true" />
) : (
index + 1
)}
</span>
<span>{label}</span>
</div>
))}
</nav>
{error ? (
<p className="design-workspace-panel__error" role="alert">
{error}
</p>
) : null}
<div className="design-workspace-panel__body">
<aside className="design-workspace-tree" aria-label="策划文件">
<header>
<strong></strong>
<span>
{files.filter((entry) => entry.kind !== 'directory').length}{' '}
</span>
</header>
{loading ? (
<p className="design-workspace-empty"></p>
) : sortedFiles.length === 0 ? (
<div className="design-workspace-empty">
<Folder size={24} aria-hidden="true" />
<strong></strong>
<span>Agent </span>
</div>
) : (
<div className="design-workspace-tree__list">
{sortedFiles.map((entry) => {
const isDirectory = entry.kind === 'directory';
return isDirectory ? (
<div
key={entry.path}
className="design-workspace-tree__entry is-directory"
style={{
paddingLeft: `${12 + pathDepth(entry.path) * 12}px`,
}}
>
<Folder size={15} aria-hidden="true" />
<span>{entry.path}</span>
</div>
) : (
<button
key={entry.path}
type="button"
className={`design-workspace-tree__entry${selectedPath === entry.path ? ' is-selected' : ''}`}
style={{
paddingLeft: `${12 + pathDepth(entry.path) * 12}px`,
}}
onClick={() => void openFile(entry.path)}
>
<File size={15} aria-hidden="true" />
<span>{entry.path}</span>
</button>
);
})}
</div>
)}
</aside>
<article className="design-workspace-preview" aria-label="策划文件预览">
{selectedEntry && selectedPath ? (
<>
<header>
<button type="button" onClick={() => setSelectedPath(null)}>
<ArrowLeft size={15} aria-hidden="true" />
</button>
<strong>{selectedPath}</strong>
</header>
{previewLoading ? (
<p className="design-workspace-empty"></p>
) : selectedPath.toLowerCase().endsWith('.md') ? (
<div className="design-workspace-preview__markdown">
<ChatMarkdownMessage role="assistant" text={previewText} />
</div>
) : (
<pre>{previewText}</pre>
)}
</>
) : (
<div className="design-workspace-preview__placeholder">
<File size={28} aria-hidden="true" />
<strong></strong>
<span> Markdown </span>
</div>
)}
</article>
</div>
</section>
);
}
@@ -401,7 +401,8 @@ export function ProjectSupervisorView({
runtimePanelProps.controlBusy ||
needsUserInput ||
modelValidating ||
Boolean(designView?.session.pendingApproval)
Boolean(designView?.session.pendingApproval) ||
Boolean(designView?.session.pendingClarification)
}
rows={3}
value={chatInput}
@@ -440,6 +441,7 @@ export function ProjectSupervisorView({
runtimePanelProps.controlBusy ||
needsUserInput ||
Boolean(designView?.session.pendingApproval) ||
Boolean(designView?.session.pendingClarification) ||
(directCodex && (!modelReady || modelValidating))
}
>
+381
View File
@@ -8171,6 +8171,387 @@ iframe.preview-frame {
padding: 12px;
}
/* 策划 Agent 使用 GameAgent 工作台的两栏外壳。左侧工作区即使为空也保持可见,
右侧只承载对话与阶段控制。 */
.game-project-workbench--design {
--game-workbench-stage-fill: #fffdfa;
--game-workbench-agent-fill: #fbf7f2;
}
.game-workbench-layout--design {
height: clamp(600px, calc(100dvh - 154px), 900px);
grid-template-columns: minmax(0, 1fr) minmax(380px, 0.42fr);
}
.game-workbench-layout--design .game-workbench-stage {
display: block;
min-height: 0;
overflow: hidden;
}
.game-workbench-layout--design .game-workbench-chat {
min-height: 0;
}
.design-workspace-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
height: 100%;
min-height: 0;
padding: 22px;
overflow: hidden;
background: var(--game-workbench-stage-fill);
}
.design-workspace-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 18px;
}
.design-workspace-panel__eyebrow {
display: block;
margin-bottom: 6px;
color: var(--platform-text-soft);
font-size: 12px;
letter-spacing: 0.08em;
}
.design-workspace-panel__header h1 {
margin: 0;
color: var(--platform-text-strong);
font-size: clamp(22px, 2.4vw, 32px);
line-height: 1.15;
}
.design-workspace-panel__header p {
margin-top: 8px;
color: var(--platform-text-base);
font-size: 13px;
}
.design-workspace-panel__refresh,
.design-workspace-preview header button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
padding: 0 11px;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
cursor: pointer;
}
.design-workspace-panel__refresh:disabled {
cursor: wait;
opacity: 0.6;
}
.design-phase-rail {
display: flex;
gap: 6px;
padding: 12px 0 18px;
overflow-x: auto;
scrollbar-width: thin;
}
.design-phase-rail__item {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--platform-line-soft);
border-radius: 999px;
color: var(--platform-text-soft);
font-size: 12px;
}
.design-phase-rail__item.is-current {
border-color: var(--platform-button-primary-border);
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
font-weight: 700;
}
.design-phase-rail__item.is-complete {
color: var(--platform-text-base);
}
.design-phase-rail__marker {
display: inline-grid;
width: 18px;
height: 18px;
place-items: center;
border-radius: 50%;
background: var(--platform-line-soft);
font-size: 10px;
}
.design-phase-rail__item.is-current .design-phase-rail__marker,
.design-phase-rail__item.is-complete .design-phase-rail__marker {
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.design-workspace-panel__error,
.design-agent-controls__error {
margin: 0 0 10px;
color: var(--platform-danger-text, #b42318);
font-size: 12px;
overflow-wrap: anywhere;
}
.design-workspace-panel__body {
display: grid;
grid-template-columns: minmax(220px, 0.34fr) minmax(0, 1fr);
min-height: 0;
overflow: hidden;
border: 1px solid var(--platform-line-soft);
border-radius: 12px;
background: var(--platform-input-fill);
}
.design-workspace-tree {
min-width: 0;
overflow: auto;
border-right: 1px solid var(--platform-line-soft);
}
.design-workspace-tree > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--platform-line-soft);
background: var(--platform-neutral-bg);
}
.design-workspace-tree > header strong {
color: var(--platform-text-strong);
font-size: 13px;
}
.design-workspace-tree > header span {
color: var(--platform-text-soft);
font-size: 11px;
}
.design-workspace-tree__list {
display: grid;
gap: 2px;
padding: 8px;
}
.design-workspace-tree__entry {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 34px;
padding-top: 0;
padding-bottom: 0;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--platform-text-base);
font: inherit;
font-size: 12px;
text-align: left;
overflow: hidden;
}
.design-workspace-tree__entry span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button.design-workspace-tree__entry {
cursor: pointer;
}
button.design-workspace-tree__entry:hover,
.design-workspace-tree__entry.is-selected {
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
.design-workspace-tree__entry.is-directory {
color: var(--platform-text-soft);
}
.design-workspace-empty,
.design-workspace-preview__placeholder {
display: grid;
place-items: center;
gap: 8px;
min-height: 150px;
padding: 24px;
color: var(--platform-text-soft);
font-size: 12px;
text-align: center;
}
.design-workspace-empty strong,
.design-workspace-preview__placeholder strong {
color: var(--platform-text-strong);
font-size: 14px;
}
.design-workspace-preview {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--platform-neutral-bg);
}
.design-workspace-preview > header {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
padding: 10px 14px;
border-bottom: 1px solid var(--platform-line-soft);
}
.design-workspace-preview > header strong {
min-width: 0;
overflow: hidden;
color: var(--platform-text-strong);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.design-workspace-preview__markdown,
.design-workspace-preview > pre {
min-height: 0;
margin: 0;
padding: 18px;
overflow: auto;
color: var(--platform-text-base);
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.design-agent-controls {
display: grid;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--platform-line-soft);
border-radius: 10px;
background: var(--platform-neutral-bg);
}
.design-agent-controls__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.design-agent-controls__header div {
display: grid;
gap: 3px;
}
.design-agent-controls__header span:first-child {
color: var(--platform-text-soft);
font-size: 11px;
}
.design-agent-controls__header strong {
color: var(--platform-text-strong);
font-size: 14px;
}
.design-agent-controls__status {
color: var(--platform-accent);
font-size: 11px;
}
.design-agent-approval,
.design-agent-clarify {
display: grid;
gap: 9px;
padding: 0;
}
.design-agent-approval p,
.design-agent-clarify p {
margin: 0;
color: var(--platform-text-base);
font-size: 12px;
line-height: 1.5;
}
.design-agent-approval > div,
.design-agent-clarify__options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.design-agent-approval button,
.design-agent-clarify button,
.design-agent-retry {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 34px;
padding: 0 11px;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.design-agent-approval button:first-child {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.design-agent-approval button:disabled,
.design-agent-clarify button:disabled,
.design-agent-retry:disabled {
cursor: wait;
opacity: 0.58;
}
.design-agent-clarify textarea {
width: 100%;
min-height: 58px;
padding: 9px 10px;
resize: vertical;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-input-fill);
color: var(--platform-text-base);
font: inherit;
font-size: 12px;
}
.design-agent-retry {
justify-self: start;
}
.gdd-approval-card__header {
display: grid;
gap: 5px;
@@ -60,6 +60,7 @@ import {
type LocalAssetCommittedEvent,
type TauriImageCanvasHostAdapter,
} from '../../features/asset-canvas/tauriImageCanvasHostAdapter';
import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel';
import {
LocalGamePreviewFrame,
resolveEmbeddedPreviewUrl,
@@ -4209,6 +4210,33 @@ export default function ProjectDevelopmentView({
!resourceEditorRoute &&
!uiEditorRoute;
if (planningStartMode) {
return (
<section
className="launcher-page launcher-project-development game-project-workbench game-project-workbench--design"
aria-label="策划工作台"
>
<div className="game-workbench-layout game-workbench-layout--design">
<section className="game-workbench-stage" aria-label="策划工作区">
<DesignWorkspacePanel projectPath={projectPath} />
</section>
<aside className="game-workbench-chat" aria-label="策划 Agent 对话">
<header>
<div className="game-workbench-chat-title">
<strong> Agent </strong>
<small></small>
</div>
{walletEntry ? (
<div className="game-workbench-chat-wallet">{walletEntry}</div>
) : null}
</header>
{supervisor}
</aside>
</div>
</section>
);
}
return (
<section
className="launcher-page launcher-project-development game-project-workbench"