完善项目工作台入口与账户兑换码

修复 DirectProject 提交时的项目快照空值类型错误

将运行中项目列表移入窗口标题栏并支持展开查看

增加项目内打开项目目录按钮

在账户资产条中直接显示兑换码入口并保留兑换弹窗

补充相关测试、类型检查与产品文档
This commit is contained in:
Git Hooks Test
2026-09-16 01:54:29 +08:00
parent 9722442b44
commit d650bf9b74
18 changed files with 712 additions and 34 deletions
+9 -2
View File
@@ -6528,8 +6528,15 @@ export function App({
// The legacy Supervisor/harness path remains below for rollback and tests.
if (directCodexProductRuntime) {
const directInvoke = resolveTauriInvoke();
const directProjectPath = resolveChatProjectPath(localProject);
if (directProjectPath && directInvoke) {
// Capture the project snapshot before any asynchronous policy/session work.
// `resolveChatProjectPath` only returns a path and TypeScript cannot infer
// that the source project is still non-null after an await; keeping the
// immutable snapshot also prevents a project switch from changing the
// projectId used by this turn halfway through submission.
const directProject = localProject;
const directProjectPath = resolveChatProjectPath(directProject);
const directProjectId = directProject?.manifest.projectId;
if (directProjectPath && directProjectId && directInvoke) {
const clientTurnId =
directConversationTurnId ?? createDirectCodexConversationTurnId();
const effectiveUserItem =
@@ -3,12 +3,14 @@ import { Copy, Minus, Square, X } from 'lucide-react';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
import { AppUpdateNotice } from './AppUpdateNotice';
import {
WINDOW_CHROME_DEFAULT_TITLE,
WindowChromeContext,
type WindowChromeContextValue,
type WindowChromeActiveProjectRuns,
} from './windowChromeContext';
type WindowChromeProps = {
@@ -39,6 +41,8 @@ function getNativeWindow() {
export function WindowChrome({ children }: WindowChromeProps) {
const [title, setTitleState] = useState(WINDOW_CHROME_DEFAULT_TITLE);
const [walletSlot, setWalletSlot] = useState<HTMLDivElement | null>(null);
const [activeProjectRuns, setActiveProjectRuns] =
useState<WindowChromeActiveProjectRuns | null>(null);
const setTitle = useCallback((nextTitle: string | null | undefined) => {
const normalizedTitle = nextTitle?.trim();
@@ -50,6 +54,8 @@ export function WindowChrome({ children }: WindowChromeProps) {
title,
setTitle,
walletSlot,
activeProjectRuns,
setActiveProjectRuns,
};
const [isMaximized, setIsMaximized] = useState(false);
@@ -142,19 +148,33 @@ export function WindowChrome({ children }: WindowChromeProps) {
</div>
<div className="window-chrome__drag-region" data-tauri-drag-region>
<span className="window-chrome__title-wrap">
<span
className="window-chrome__workspace-dot"
aria-hidden="true"
/>
<span
className="window-chrome__title"
title={title}
aria-label={`当前工作区:${title}`}
>
{title}
</span>
</span>
<div className="window-chrome__title-wrap">
{activeProjectRuns &&
(activeProjectRuns.activeTurns.length > 0 ||
activeProjectRuns.readFailed) ? (
<ActiveProjectRunsPanel
activeTurns={activeProjectRuns.activeTurns}
currentProjectPath={activeProjectRuns.currentProjectPath}
readFailed={activeProjectRuns.readFailed}
onOpenProject={activeProjectRuns.onOpenProject}
placement="titlebar"
/>
) : (
<>
<span
className="window-chrome__workspace-dot"
aria-hidden="true"
/>
<span
className="window-chrome__title"
title={title}
aria-label={`当前工作区:${title}`}
>
{title}
</span>
</>
)}
</div>
</div>
<div className="window-chrome__trailing">
@@ -1,5 +1,7 @@
import { createContext, useContext } from 'react';
import type { GameCreatorDirectActiveTurn } from '../app/types';
export const WINDOW_CHROME_DEFAULT_TITLE = '创作工作台';
export type WindowChromeContextValue = {
@@ -7,6 +9,17 @@ export type WindowChromeContextValue = {
title: string;
setTitle: (title: string | null | undefined) => void;
walletSlot: HTMLElement | null;
activeProjectRuns: WindowChromeActiveProjectRuns | null;
setActiveProjectRuns: (
activeProjectRuns: WindowChromeActiveProjectRuns | null,
) => void;
};
export type WindowChromeActiveProjectRuns = {
activeTurns: GameCreatorDirectActiveTurn[];
currentProjectPath?: string | null;
readFailed?: boolean;
onOpenProject?: (projectPath: string) => void;
};
export const WindowChromeContext = createContext<WindowChromeContextValue>({
@@ -14,6 +27,8 @@ export const WindowChromeContext = createContext<WindowChromeContextValue>({
title: WINDOW_CHROME_DEFAULT_TITLE,
setTitle: () => undefined,
walletSlot: null,
activeProjectRuns: null,
setActiveProjectRuns: () => undefined,
});
export function useWindowChrome() {
@@ -1,6 +1,7 @@
import { PlatformMudPointWalletEntry } from '../../../../../packages/shared/src/components/PlatformMudPointWalletEntry';
import { PlatformProfileRechargeModal } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileWalletLedgerModal } from '../../../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import { ThemedModal } from '../../components/modal/ThemedModal';
import type { AccountWalletController } from './useAccountWallet';
export function AccountWalletBar({
@@ -21,6 +22,7 @@ export function AccountWalletBar({
onRequestDetails={() => void controller.onWalletBalanceMayHaveChanged()}
onRecharge={controller.openRecharge}
onOpenLedger={controller.openWalletLedger}
onRedeemCode={controller.openRedeemCode}
/>
</div>
);
@@ -63,6 +65,37 @@ export function AccountWalletDialogs({
onRetry={() => void controller.loadWalletLedger()}
/>
) : null}
<ThemedModal
open={controller.redeemCodeOpen}
ariaLabel="兑换码"
onClose={controller.closeRedeemCode}
panelClassName="launcher-redeem-modal"
>
<header className="launcher-redeem-modal-header">
<strong></strong>
<button type="button" onClick={controller.closeRedeemCode} aria-label="关闭兑换码">×</button>
</header>
<form
className="launcher-redeem-modal-form"
onSubmit={(event) => {
event.preventDefault();
void controller.redeemCode();
}}
>
<input
value={controller.redeemCodeInput}
onChange={(event) => controller.setRedeemCodeInput(event.target.value)}
placeholder="输入兑换码"
aria-label="兑换码"
autoFocus
/>
{controller.redeemCodeError ? <p role="alert">{controller.redeemCodeError}</p> : null}
{controller.redeemCodeSuccess ? <p role="status">{controller.redeemCodeSuccess}</p> : null}
<button type="submit" disabled={controller.redeemCodeLoading || !controller.redeemCodeInput.trim()}>
{controller.redeemCodeLoading ? '兑换中' : '兑换'}
</button>
</form>
</ThemedModal>
</>
);
}
@@ -1,9 +1,12 @@
import { ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { GameCreatorDirectActiveTurn } from '../../app/types';
import { projectNameFromPath } from '../agent-runtime';
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
/**
* 左上角的"正在运行的项目"面板
* 窗口标题栏的"正在运行的项目"入口,也保留面板布局供独立组件测试和复用
*
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连),
* 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时
@@ -14,6 +17,7 @@ export type ActiveProjectRunsPanelProps = {
currentProjectPath?: string | null;
readFailed?: boolean;
onOpenProject?: (projectPath: string) => void;
placement?: 'panel' | 'titlebar';
};
const ACTIVE_TURN_STATUS_LABELS: Record<string, string> = {
@@ -56,11 +60,47 @@ export function ActiveProjectRunsPanel({
currentProjectPath = null,
readFailed = false,
onOpenProject,
placement = 'panel',
}: ActiveProjectRunsPanelProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open || placement !== 'titlebar') {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open, placement]);
if (activeTurns.length === 0) {
if (!readFailed) {
return null;
}
if (placement === 'titlebar') {
return (
<span
className="launcher-runs-titlebar launcher-runs-titlebar--error"
role="status"
>
</span>
);
}
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
return (
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
@@ -75,6 +115,91 @@ export function ActiveProjectRunsPanel({
const orderedTurns = [...activeTurns].sort(
(left, right) => left.startedAt - right.startedAt,
);
if (placement === 'titlebar') {
const latestTurn = orderedTurns[orderedTurns.length - 1];
if (!latestTurn) {
return null;
}
const latestName = activeTurnDisplayName(latestTurn);
const openProject = (projectPath: string) => {
setOpen(false);
onOpenProject?.(projectPath);
};
return (
<div
ref={menuRef}
className="launcher-runs-titlebar"
data-active-project-count={orderedTurns.length}
>
<button
type="button"
className="launcher-runs-titlebar-trigger"
aria-label={`正在运行的项目:${latestName}`}
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((current) => !current)}
>
<span className="launcher-runs-panel-dot" aria-hidden="true" />
<span className="launcher-runs-titlebar-name" title={latestName}>
{latestName}
</span>
{orderedTurns.length > 1 ? (
<span className="launcher-runs-titlebar-count">
{orderedTurns.length}
</span>
) : null}
<ChevronDown
className={`launcher-runs-titlebar-chevron${open ? ' is-open' : ''}`}
size={14}
aria-hidden="true"
/>
</button>
{open ? (
<div className="launcher-runs-titlebar-menu" role="menu">
<div className="launcher-runs-titlebar-menu-header">
<strong></strong>
<span>{orderedTurns.length} </span>
</div>
<ul className="launcher-runs-titlebar-list">
{orderedTurns.map((turn) => {
const name = activeTurnDisplayName(turn);
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
const isCurrent = Boolean(
currentProjectPath &&
projectPathsMatchForInvalidation(
turn.projectPath,
currentProjectPath,
),
);
return (
<li key={`${turn.projectPath}:${turn.turnId}`}>
<button
type="button"
role="menuitem"
className="launcher-runs-titlebar-item"
aria-current={isCurrent ? 'true' : undefined}
disabled={!onOpenProject}
onClick={() => openProject(turn.projectPath)}
>
<span className="launcher-runs-titlebar-item-name" title={name}>
{name}
</span>
<span className="launcher-runs-titlebar-item-meta">
{[activeTurnStatusLabel(turn.status), elapsed]
.filter(Boolean)
.join(' · ')}
</span>
</button>
</li>
);
})}
</ul>
</div>
) : null}
</div>
);
}
return (
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
<header className="launcher-runs-panel-header">
@@ -28,7 +28,6 @@ import {
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel';
import {
DeveloperAgentDialogs,
DeveloperAgentPanel,
@@ -50,6 +49,7 @@ export function WorkspaceLauncherShell({
isWindowChrome,
setTitle: setWindowTitle,
walletSlot,
setActiveProjectRuns,
} = useWindowChrome();
const accountWallet = useAccountWallet(currentUser.id);
const [status, setStatus] = useState('');
@@ -197,6 +197,31 @@ export function WorkspaceLauncherShell({
* 清掉就等于这条提示时有时无。所以只有真的从 A 项目切到 B 项目(或关掉项目)才清。
*/
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
const openActiveProject = useCallback(
(nextProjectPath: string) => {
setProjectPath(nextProjectPath);
void openProject(nextProjectPath, 'open');
},
[openProject, setProjectPath],
);
useEffect(() => {
setActiveProjectRuns({
activeTurns,
currentProjectPath: currentProjectContext?.projectPath ?? null,
readFailed: snapshotReadFailed,
onOpenProject: openActiveProject,
});
return () => setActiveProjectRuns(null);
}, [
activeTurns,
currentProjectContext?.projectPath,
openActiveProject,
setActiveProjectRuns,
snapshotReadFailed,
]);
useEffect(() => {
const projectPath = currentProjectContext?.projectPath ?? null;
const previousProjectPath = manifestMergeNoticeScopeRef.current;
@@ -524,16 +549,6 @@ export function WorkspaceLauncherShell({
</header>
) : null}
<ActiveProjectRunsPanel
activeTurns={activeTurns}
currentProjectPath={currentProjectContext?.projectPath ?? null}
readFailed={snapshotReadFailed}
onOpenProject={(nextProjectPath) => {
setProjectPath(nextProjectPath);
void openProject(nextProjectPath, 'open');
}}
/>
{launcherView === 'home' ? (
<HomeView
hasPromo={launcherNotifications.length > 0}
@@ -631,6 +646,11 @@ export function WorkspaceLauncherShell({
onMakeGame={() =>
void switchToGameRuntime(currentProjectContext.projectPath)
}
onRevealProjectDirectory={() =>
recentProjects.handleRevealProjectDirectory(
currentProjectContext.projectPath,
)
}
onManifestChange={syncActiveProjectManifest}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
@@ -12,6 +12,7 @@ import {
createClientProfileRechargeOrder,
getClientProfileRechargeCenter,
getClientProfileWalletLedger,
redeemClientProfileRewardCode,
} from '../../services/clientApi';
import { useWalletStore } from '../../stores/useWalletStore';
@@ -45,8 +46,14 @@ export function useAccountWallet(currentUserId: string) {
useState<string | null>(null);
const [nativeRechargePayment, setNativeRechargePayment] =
useState<PlatformProfileRechargeNativePaymentState | null>(null);
const [redeemCodeOpen, setRedeemCodeOpen] = useState(false);
const [redeemCodeInput, setRedeemCodeInput] = useState('');
const [redeemCodeLoading, setRedeemCodeLoading] = useState(false);
const [redeemCodeError, setRedeemCodeError] = useState<string | null>(null);
const [redeemCodeSuccess, setRedeemCodeSuccess] = useState<string | null>(null);
const rechargeLifecycleRef = useRef(0);
const walletLedgerLifecycleRef = useRef(0);
const redeemLifecycleRef = useRef(0);
const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId);
const currentUserIdRef = useRef(currentUserId);
const walletOwnerMatchesCurrentUser =
@@ -105,6 +112,12 @@ export function useAccountWallet(currentUserId: string) {
setRechargeError(null);
setSubmittingRechargeProductId(null);
setNativeRechargePayment(null);
redeemLifecycleRef.current += 1;
setRedeemCodeOpen(false);
setRedeemCodeInput('');
setRedeemCodeLoading(false);
setRedeemCodeError(null);
setRedeemCodeSuccess(null);
}, [currentUserId]);
async function loadWalletLedger() {
@@ -214,6 +227,45 @@ export function useAccountWallet(currentUserId: string) {
setSubmittingRechargeProductId(null);
}
function openRedeemCode() {
redeemLifecycleRef.current += 1;
setRedeemCodeOpen(true);
setRedeemCodeInput('');
setRedeemCodeError(null);
setRedeemCodeSuccess(null);
}
function closeRedeemCode() {
redeemLifecycleRef.current += 1;
setRedeemCodeOpen(false);
setRedeemCodeLoading(false);
}
async function redeemCode() {
const code = redeemCodeInput.trim();
if (!code || redeemCodeLoading) return;
const lifecycle = redeemLifecycleRef.current;
const owner = currentUserId;
setRedeemCodeLoading(true);
setRedeemCodeError(null);
setRedeemCodeSuccess(null);
try {
const response = await redeemClientProfileRewardCode(code);
if (redeemLifecycleRef.current !== lifecycle || currentUserIdRef.current !== owner) return;
setRedeemCodeSuccess(`兑换成功,已到账 ${response.amountGranted} 泥点`);
setRedeemCodeInput('');
void onWalletBalanceMayHaveChanged();
} catch (error) {
if (redeemLifecycleRef.current === lifecycle && currentUserIdRef.current === owner) {
setRedeemCodeError(error instanceof Error ? error.message : '兑换失败');
}
} finally {
if (redeemLifecycleRef.current === lifecycle && currentUserIdRef.current === owner) {
setRedeemCodeLoading(false);
}
}
}
async function buyRechargeProduct(product: ProfileRechargeProduct) {
if (submittingRechargeProductId) {
return;
@@ -359,6 +411,15 @@ export function useAccountWallet(currentUserId: string) {
closeRecharge,
buyRechargeProduct,
confirmNativeRechargePayment,
redeemCodeOpen: walletUiIsVisible && redeemCodeOpen,
redeemCodeInput,
redeemCodeLoading: walletUiIsVisible && redeemCodeLoading,
redeemCodeError: walletUiIsVisible ? redeemCodeError : null,
redeemCodeSuccess: walletUiIsVisible ? redeemCodeSuccess : null,
setRedeemCodeInput,
openRedeemCode,
closeRedeemCode,
redeemCode,
};
}
@@ -6,6 +6,7 @@ import {
ProfileDashboardSummary,
ProfileRechargeCenterResponse,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
unwrapApiResponse,
} from '../../../../packages/shared/src';
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
@@ -303,3 +304,15 @@ export function getClientProfileWalletLedger() {
'读取泥点账单失败',
);
}
export function redeemClientProfileRewardCode(code: string) {
return requestClientApi<RedeemProfileRewardCodeResponse>(
'/api/profile/redeem-codes/redeem',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
},
'兑换失败',
);
}
+230 -3
View File
@@ -358,7 +358,9 @@ body {
.window-chrome__title-wrap {
position: relative;
display: block;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
max-width: min(42vw, 460px, 100%);
}
@@ -384,6 +386,162 @@ body {
transform: translateY(-50%);
}
.launcher-runs-titlebar {
position: relative;
display: inline-flex;
min-width: 0;
max-width: min(42vw, 460px, 100%);
-webkit-app-region: no-drag;
}
.launcher-runs-titlebar-trigger {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
gap: 7px;
padding: 6px 9px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--platform-text-base, #6f5848);
font: inherit;
cursor: pointer;
transition:
background 150ms ease,
border-color 150ms ease,
color 150ms ease;
}
.launcher-runs-titlebar-trigger:hover,
.launcher-runs-titlebar-trigger:focus-visible,
.launcher-runs-titlebar-trigger[aria-expanded='true'] {
border-color: var(--platform-surface-border, #ead8cb);
background: rgb(255 255 255 / 72%);
color: var(--platform-text-strong, #3d1f10);
outline: none;
}
.launcher-runs-titlebar-name {
min-width: 0;
overflow: hidden;
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.launcher-runs-titlebar-count {
display: inline-grid;
min-width: 18px;
height: 18px;
padding: 0 4px;
place-items: center;
border-radius: 9px;
background: rgb(199 101 61 / 12%);
color: var(--platform-accent, #c7653d);
font-size: 10px;
font-weight: 750;
}
.launcher-runs-titlebar-chevron {
flex: 0 0 auto;
color: var(--platform-text-muted, #a38f80);
transition: transform 150ms ease;
}
.launcher-runs-titlebar-chevron.is-open {
transform: rotate(180deg);
}
.launcher-runs-titlebar-menu {
position: absolute;
top: calc(100% + 8px);
left: 50%;
z-index: 40;
width: min(360px, calc(100vw - 32px));
padding: 10px;
border: 1px solid var(--platform-subpanel-border, #ead8cb);
border-radius: 12px;
background: var(--platform-subpanel-fill, #fffaf5);
box-shadow: 0 14px 36px rgb(31 24 16 / 18%);
color: var(--platform-text-base, #6f5848);
transform: translateX(-50%);
}
.launcher-runs-titlebar-menu-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 5px 8px;
border-bottom: 1px solid var(--platform-surface-border, #ead8cb);
color: var(--platform-text-strong, #3d1f10);
font-size: 11px;
}
.launcher-runs-titlebar-menu-header span {
color: var(--platform-text-muted, #a38f80);
font-size: 10px;
}
.launcher-runs-titlebar-list {
display: grid;
gap: 3px;
max-height: min(52vh, 360px);
margin: 7px 0 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.launcher-runs-titlebar-item {
display: grid;
width: 100%;
gap: 3px;
padding: 8px 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.launcher-runs-titlebar-item:hover,
.launcher-runs-titlebar-item:focus-visible,
.launcher-runs-titlebar-item[aria-current='true'] {
background: rgb(199 101 61 / 10%);
outline: none;
}
.launcher-runs-titlebar-item:disabled {
cursor: default;
}
.launcher-runs-titlebar-item-name {
overflow: hidden;
color: var(--platform-text-strong, #3d1f10);
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.launcher-runs-titlebar-item-meta,
.launcher-runs-titlebar--error {
color: var(--platform-text-muted, #a38f80);
font-size: 10px;
}
.launcher-runs-titlebar--error {
display: inline-flex;
align-items: center;
padding: 5px 8px;
border-radius: 7px;
background: rgb(199 101 61 / 8%);
}
.window-chrome__trailing {
grid-column: 3;
position: relative;
@@ -530,8 +688,8 @@ body {
}
.window-chrome__drag-region {
display: none;
padding-inline: 0;
display: grid;
padding-inline: 48px;
}
.window-chrome__leading {
@@ -1026,6 +1184,75 @@ textarea {
top: 44px;
}
.launcher-redeem-modal {
width: min(360px, calc(100vw - 32px));
padding: 0;
border: 1px solid var(--platform-subpanel-border);
border-radius: 18px;
overflow: hidden;
}
.launcher-redeem-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--platform-surface-border);
font-size: 15px;
}
.launcher-redeem-modal-header button {
border: 0;
background: transparent;
color: var(--platform-text-muted);
font-size: 20px;
cursor: pointer;
}
.launcher-redeem-modal-form {
display: grid;
gap: 12px;
padding: 18px;
}
.launcher-redeem-modal-form input {
width: 100%;
box-sizing: border-box;
padding: 11px 12px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 10px;
background: var(--platform-body-fill);
color: var(--platform-text-strong);
}
.launcher-redeem-modal-form p {
margin: 0;
font-size: 12px;
}
.launcher-redeem-modal-form p[role='alert'] {
color: #b5432e;
}
.launcher-redeem-modal-form p[role='status'] {
color: #34804b;
}
.launcher-redeem-modal-form > button {
padding: 11px 14px;
border: 0;
border-radius: 10px;
background: var(--platform-accent, #c7653d);
color: #fff;
font-weight: 700;
cursor: pointer;
}
.launcher-redeem-modal-form > button:disabled {
cursor: default;
opacity: 0.5;
}
.launcher-project-list-actions {
display: flex;
align-items: center;
@@ -19,6 +19,7 @@ import {
AtSign,
Crosshair,
FileText,
FolderOpen,
FolderTree,
Gamepad2,
Image,
@@ -586,6 +587,7 @@ export type ProjectDevelopmentViewProps = {
onProjectsOpen: () => void;
onPlay?: () => void;
onMakeGame?: () => void;
onRevealProjectDirectory?: () => void | Promise<void>;
onManifestChange?: (
projectPath: string,
manifest: GameCreationAppManifest,
@@ -1446,6 +1448,7 @@ export default function ProjectDevelopmentView({
onManifestChange,
onPlay,
onMakeGame,
onRevealProjectDirectory,
}: ProjectDevelopmentViewProps) {
const professionalDagVisible = orchestrationMode === 'professional-dag';
const [mode, setMode] = useState<WorkbenchMode>('resources');
@@ -7633,6 +7636,17 @@ export default function ProjectDevelopmentView({
</button>
</div>
<div className="game-workbench-view-actions">
{onRevealProjectDirectory ? (
<button
type="button"
className="game-workbench-resource-panel-button"
aria-label="打开项目目录"
onClick={() => void onRevealProjectDirectory()}
>
<FolderOpen size={15} aria-hidden="true" />
</button>
) : null}
{mode === 'run' && embeddedPreviewUrl ? (
<button
type="button"
@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
import { WindowChrome } from '../src/components/WindowChrome';
import { useWindowChrome } from '../src/components/windowChromeContext';
@@ -16,6 +17,18 @@ function TitleSetter({ value }: { value: string }) {
);
}
function ActiveRunsSetter({ activeTurns }: { activeTurns: GameCreatorDirectActiveTurn[] }) {
const { setActiveProjectRuns } = useWindowChrome();
return (
<button
type="button"
onClick={() => setActiveProjectRuns({ activeTurns, onOpenProject: () => undefined })}
>
</button>
);
}
describe('WindowChrome', () => {
it('renders the陶泥儿 brand, default title, and controls', async () => {
const user = userEvent.setup();
@@ -82,4 +95,40 @@ describe('WindowChrome', () => {
fireEvent.pointerDown(screen.getByRole('button', { name: '页面按钮' }));
expect(screen.queryByRole('menu')).toBeNull();
});
it('renders the latest active project in the title bar and expands the full list', async () => {
const user = userEvent.setup();
render(
<WindowChrome>
<ActiveRunsSetter
activeTurns={[
{
projectPath: 'C:/projects/first',
projectName: '先开始',
turnId: 'turn-first',
startedAt: 100,
status: 'running',
updatedAt: 120,
sequence: 1,
},
{
projectPath: 'C:/projects/later',
projectName: '后开始',
turnId: 'turn-later',
startedAt: 200,
status: 'streaming',
updatedAt: 220,
sequence: 2,
},
]}
/>
</WindowChrome>,
);
await user.click(screen.getByRole('button', { name: '显示运行项目' }));
expect(screen.getByRole('button', { name: /正在运行的项目:后开始/ })).toBeTruthy();
expect(screen.queryByRole('menu')).toBeNull();
await user.click(screen.getByRole('button', { name: /正在运行的项目:后开始/ }));
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
});
});
@@ -49,3 +49,41 @@ it('读取失败时保留明确的读取提示,不伪装成没有运行项目'
expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目');
});
it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
const onOpenProject = vi.fn();
render(
<ActiveProjectRunsPanel
placement="titlebar"
activeTurns={[
{
projectPath: 'C:/projects/first',
projectName: '先开始',
turnId: 'turn-first',
startedAt: 100,
status: 'running',
updatedAt: 120,
sequence: 1,
},
{
projectPath: 'C:/projects/later',
projectName: '后开始',
turnId: 'turn-later',
startedAt: 200,
status: 'streaming',
updatedAt: 220,
sequence: 2,
},
]}
onOpenProject={onOpenProject}
/>,
);
expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
expect(screen.queryByRole('menu')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
expect(screen.getByRole('menu')).toBeTruthy();
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
});
@@ -111,6 +111,10 @@
2026-08-23:项目开发工作台取消顶部账户资产预留空间,资源管理主视窗与 Agent 对话从窗口顶端铺开;泥点余额 / 充值入口复用既有账户组件并放置在 Agent 对话标题栏右上角,对话标题在该视窗顶部居中。
2026-09-16:项目内工具栏增加“打开项目目录”入口,资源管理与运行页签均可使用;入口复用现有本地项目目录打开命令,不新增项目状态或文件访问链路。
2026-09-16:客户端标题栏账户资产条在传入兑换能力时直接显示“兑换码”按钮;兑换弹窗和账号生命周期校验继续复用现有实现。
- 项目工作台继续保留左侧平台导航、中央主视窗、右侧 Project Supervisor 和底部专业 Agent 状态栏四区结构;主站图片编辑器只作为视觉语言和共享画布组件的事实源,不把其素材库侧栏、账号业务或云端项目外壳整体搬入客户端。
- 平台主题事实源固定为 `packages/shared/src/theme.css`。画布通用 chrome 固定落在 `@genarrative/image-canvas-react`,主站与 Tauri 必须直接 import 同一组件和作用域样式;客户端不得复制 `src/components/image-editor/`,也不得导入主站完整 `src/index.css`
- 第一批共享 chrome 固定覆盖画布动作按钮、工具栏、工具分组和分隔符。按钮的默认、悬停、键盘焦点、选中、禁用和主次色语义由共享层表达;宿主只提供图标、文案、事件与业务禁用条件。
@@ -29,7 +29,7 @@ Milestone: `【里程碑】Direct回合跨页面生命周期与运行中项目
1. Rust:扩展活动回合注册表并暴露只读快照命令,配定向用例(进入 / 进度 / 终态移除 / 多项目并存)。
2. 前端:接入快照读取,实现“重新进入项目 → 恢复忙碌态与进度 → 以快照 sequence 续接 → 阻止并发提交”。
3. 前端:在左上角空白区域挂载“正在运行的项目”面板,复用既有组件与设计 token。
3. 前端:在窗口标题栏挂载“正在运行的项目”下拉入口;标题栏只显示最后开始的项目,展开后按开始时间列出全部项目,复用既有组件与设计 token。
4. 报错归类:按审计结论修正会误导的映射,逐条加回归用例;真实权限拒绝保持原提示。
5. 文档:主规范与共享记忆同步;里程碑验收后删除临时计划文件。
@@ -7,7 +7,7 @@ Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施
## 目标
离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层左上角提供“正在运行的项目”面板,列出当前确有在跑回合的项目并可点击进入。
离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层窗口标题栏提供“正在运行的项目”下拉入口,常态显示最后开始的项目,展开后列出当前确有在跑回合的全部项目并可点击进入。
## 边界
@@ -22,7 +22,7 @@ Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施
- 重新进入有在跑回合的项目后:界面进入“正在处理”、显示最近一次进度、以快照 `sequence` 续接后续事件;回合结束前提交第二条需求不会真正发起第二条付费回合。
- 回合结束(completed / failed)后:忙碌态解除、可以再次发送;不重复追加助手消息。
- 无在跑回合的项目:行为与今天一致(可正常发送,不出现额外提示或阻塞)。
- 左上角面板:列出所有在跑项目,`startedAt` 升序,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染面板外壳
- 窗口标题栏入口:常态只显示最后开始的项目,点击后`startedAt` 升序列出所有在跑项目,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染入口
- 快照读取失败不得阻断发送、不得显示成业务失败。
- 已修的错误映射不回归:`direct-codex-turn-already-running:` 与历史同义中文正文都归一到“仍在处理这个项目的上一条需求”;真正的 `项目权限策略拒绝执行:<command>` 仍显示审批提示。
@@ -1407,6 +1407,6 @@ DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执
Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。
壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。
壳层窗口标题栏的“正在运行”下拉入口只呈现活动 Direct 回合快照:常态只显示最后开始的项目,展开后按开始时间排序,显示全部项目的项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;入口不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。
活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。
@@ -60,6 +60,25 @@ test('shows only permanent and daily free points in the shared wallet panel', as
expect(onRecharge).toHaveBeenCalledTimes(1);
});
test('shows the redeem-code entry directly in the wallet bar when provided', async () => {
const user = userEvent.setup();
const onRedeemCode = vi.fn();
render(
<PlatformMudPointWalletEntry
balance={207}
breakdown={breakdown}
onRequestDetails={vi.fn()}
onRecharge={vi.fn()}
onOpenLedger={vi.fn()}
onRedeemCode={onRedeemCode}
/>,
);
await user.click(screen.getByRole('button', { name: '兑换码' }));
expect(onRedeemCode).toHaveBeenCalledTimes(1);
});
test('keeps the chip on the live balance when cached details are stale', () => {
const props = {
breakdown,
@@ -22,6 +22,7 @@ export type PlatformMudPointWalletEntryProps = {
onRequestDetails: () => void;
onRecharge: () => void;
onOpenLedger: () => void;
onRedeemCode?: () => void;
};
function MudPointBalanceRow({
@@ -62,6 +63,7 @@ export function PlatformMudPointWalletEntry({
onRequestDetails,
onRecharge,
onOpenLedger,
onRedeemCode,
}: PlatformMudPointWalletEntryProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const isOpenRef = useRef(false);
@@ -224,6 +226,24 @@ export function PlatformMudPointWalletEntry({
>
</button>
{onRedeemCode ? (
<>
<span
className="my-1.5 w-px shrink-0 bg-[rgba(196,153,120,0.36)]"
aria-hidden="true"
/>
<button
type="button"
className="shrink-0 px-2.5 font-black outline-none transition-colors hover:bg-white/75 focus-visible:bg-white/80"
onClick={() => {
closeDetails();
onRedeemCode();
}}
>
</button>
</>
) : null}
</div>
{isOpen ? (
@@ -281,6 +301,19 @@ export function PlatformMudPointWalletEntry({
使
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{onRedeemCode ? (
<button
type="button"
className="flex w-full items-center justify-center gap-2 border-t border-[rgba(194,145,111,0.28)] px-4 py-3 text-[13px] font-black text-[var(--platform-text-strong)] outline-none transition-colors hover:bg-white/55 focus-visible:bg-white/65"
onClick={() => {
closeDetails();
onRedeemCode();
}}
>
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : null}
</div>
) : null}
</div>