Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/desktop/src/main/__tests__/plan-mode-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getPlanModeCopy } from '../../renderer/locales/plan-mode-copy.js';

test('localizes Plan Mode chrome and abandon confirmation without rewriting plan content', () => {
const zh = getPlanModeCopy('zh');
const en = getPlanModeCopy('en');

assert.equal(zh.proposal.statuses.approved, '已批准');
assert.equal(en.proposal.statuses.approved, 'Approved');
assert.equal(zh.execution.stepCount(2, 3), '2/3 步');
assert.equal(en.execution.stepCount(1, 1), '1/1 step');
assert.deepEqual(
{
title: en.abandonConfirmation.title,
description: en.abandonConfirmation.description('Release plan'),
confirm: en.abandonConfirmation.confirm,
},
{
title: 'Abandon this plan?',
description: 'The execution record for “Release plan” will remain, but it cannot be resumed.',
confirm: 'Abandon plan',
},
);
});
76 changes: 76 additions & 0 deletions apps/desktop/src/renderer/locales/plan-mode-copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { PlanExecutionStep, PlanProposal, UiCatalog, UiLocale } from '@maka/core';

export interface PlanModeCopy {
readonly abandonConfirmation: {
readonly title: string;
description(title: string): string;
readonly confirm: string;
readonly cancel: string;
};
readonly proposal: {
readonly aria: string;
readonly kicker: string;
readonly revision: string;
readonly steps: string;
readonly risks: string;
readonly revise: string;
readonly execute: string;
readonly statuses: Record<PlanProposal['status'], string>;
};
readonly execution: {
readonly aria: string;
readonly interrupted: string;
readonly running: string;
readonly approvedPlan: string;
stepCount(completed: number, total: number): string;
readonly resume: string;
readonly abandon: string;
readonly stepStatuses: Record<PlanExecutionStep['status'], string>;
};
}

const COPY = {
zh: {
abandonConfirmation: {
title: '放弃这个计划?',
description: (title) => `“${title}”的执行记录会保留,但之后不能继续恢复。`,
confirm: '放弃计划',
cancel: '取消',
},
proposal: {
aria: '计划方案', kicker: '计划方案', revision: '修订版', steps: '执行步骤',
risks: '风险', revise: '继续修改', execute: '执行计划',
statuses: { pending_approval: '等待确认', approved: '已批准', stale: '已过期' },
},
execution: {
aria: '计划执行状态', interrupted: '计划已中断', running: '正在执行计划',
approvedPlan: '已批准计划', stepCount: (completed, total) => `${completed}/${total} 步`,
resume: '恢复执行', abandon: '放弃计划',
stepStatuses: { pending: '未开始', in_progress: '正在执行', completed: '已完成', skipped: '已跳过' },
},
},
en: {
abandonConfirmation: {
title: 'Abandon this plan?',
description: (title) => `The execution record for “${title}” will remain, but it cannot be resumed.`,
confirm: 'Abandon plan',
cancel: 'Cancel',
},
proposal: {
aria: 'Plan proposal', kicker: 'Plan proposal', revision: 'Revision', steps: 'Steps',
risks: 'Risks', revise: 'Request changes', execute: 'Execute plan',
statuses: { pending_approval: 'Waiting for approval', approved: 'Approved', stale: 'Outdated' },
},
execution: {
aria: 'Plan execution status', interrupted: 'Plan interrupted', running: 'Executing plan',
approvedPlan: 'Approved plan',
stepCount: (completed, total) => `${completed}/${total} ${total === 1 ? 'step' : 'steps'}`,
resume: 'Resume', abandon: 'Abandon plan',
stepStatuses: { pending: 'Not started', in_progress: 'In progress', completed: 'Completed', skipped: 'Skipped' },
},
},
} satisfies UiCatalog<PlanModeCopy>;

export function getPlanModeCopy(locale: UiLocale): PlanModeCopy {
return COPY[locale];
}
69 changes: 37 additions & 32 deletions apps/desktop/src/renderer/plan-mode-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import type {
} from '@maka/core';
import { Banner } from '@astryxdesign/core/Banner';
import { Collapsible } from '@astryxdesign/core/Collapsible';
import { Badge, type BadgeVariant, Button as UiButton, useToast } from '@maka/ui';
import { Badge, type BadgeVariant, Button as UiButton, useToast, useUiLocale } from '@maka/ui';
import { getPlanModeCopy, type PlanModeCopy } from './locales/plan-mode-copy.js';

export interface PlanModeState {
state: PlanSessionState | undefined;
Expand All @@ -22,6 +23,7 @@ export interface PlanModeState {

export function usePlanModeState(session: SessionSummary | undefined): PlanModeState {
const toastApi = useToast();
const copy = getPlanModeCopy(useUiLocale());
const [state, setState] = useState<PlanSessionState>();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string>();
Expand Down Expand Up @@ -134,17 +136,17 @@ export function usePlanModeState(session: SessionSummary | undefined): PlanModeS
const abandon = useCallback(async (executionId: string, title: string): Promise<void> => {
if (!session) return;
const confirmed = await toastApi.confirm({
title: '放弃这个计划?',
description: `“${title}”的执行记录会保留,但之后不能继续恢复。`,
confirmLabel: '放弃计划',
cancelLabel: '取消',
title: copy.abandonConfirmation.title,
description: copy.abandonConfirmation.description(title),
confirmLabel: copy.abandonConfirmation.confirm,
cancelLabel: copy.abandonConfirmation.cancel,
destructive: true,
});
if (!confirmed) return;
await run(async () => {
await window.maka.sessions.abandonPlanExecution(session.id, executionId);
});
}, [run, session?.id, toastApi]);
}, [copy, run, session?.id, toastApi]);

return { state, pending, error, requestRevision, approve, resume, abandon };
}
Expand All @@ -154,36 +156,37 @@ export function PlanProposalCard(props: {
planMode: PlanModeState;
}): JSX.Element {
const { proposal, planMode } = props;
const copy = getPlanModeCopy(useUiLocale()).proposal;
const reviewable =
proposal.status === 'pending_approval'
&& planMode.state?.latestProposalId === proposal.proposalId;

return (
<section className="plan-mode-panel" aria-label="计划方案">
<section className="plan-mode-panel" aria-label={copy.aria}>
<div className="plan-proposal-card" data-status={proposal.status}>
<div className="plan-proposal-heading">
<div className="plan-proposal-title">
<span className="plan-proposal-kicker">计划方案</span>
<span className="plan-proposal-kicker">{copy.kicker}</span>
<strong>{proposal.title}</strong>
</div>
<div className="plan-proposal-meta">
<Badge
className="plan-proposal-revision"
label={
<>
Revision <code>{proposal.revision}</code>
{copy.revision} <code>{proposal.revision}</code>
</>
}
/>
<Badge
variant={proposalStatusVariant(proposal.status)}
label={proposalStatusLabel(proposal.status)}
label={proposalStatusLabel(proposal.status, copy)}
/>
</div>
</div>
{proposal.overview && <p className="plan-proposal-overview">{proposal.overview}</p>}
<div className="plan-proposal-section">
<h3>执行步骤</h3>
<h3>{copy.steps}</h3>
<ol className="plan-proposal-steps">
{proposal.steps.map((step, index) => (
<li key={step.id}>
Expand All @@ -198,7 +201,7 @@ export function PlanProposalCard(props: {
</div>
{proposal.risks && proposal.risks.length > 0 && (
<div className="plan-proposal-section plan-proposal-risks">
<h3>风险</h3>
<h3>{copy.risks}</h3>
<ul>
{proposal.risks.map((risk, index) => <li key={`${index}:${risk}`}>{risk}</li>)}
</ul>
Expand All @@ -211,14 +214,14 @@ export function PlanProposalCard(props: {
size="sm"
isDisabled={planMode.pending}
onClick={() => void planMode.requestRevision(proposal.proposalId)}
label="继续修改"
label={copy.revise}
/>
<UiButton
variant="primary"
size="sm"
isDisabled={planMode.pending}
onClick={() => void planMode.approve(proposal)}
label="执行计划"
label={copy.execute}
/>
</div>
)}
Expand All @@ -234,6 +237,7 @@ export function PlanExecutionPanel(props: {
planMode: PlanModeState;
}): JSX.Element | null {
const { planMode } = props;
const copy = getPlanModeCopy(useUiLocale()).execution;
const [expanded, setExpanded] = useState(false);
const detailsId = useId();
const active = planMode.state?.executions.find(
Expand All @@ -256,19 +260,19 @@ export function PlanExecutionPanel(props: {
).length;

return (
<section className="plan-execution-panel" aria-label="计划执行状态">
<section className="plan-execution-panel" aria-label={copy.aria}>
<Collapsible
className="plan-execution-toggle"
isOpen={expanded}
onOpenChange={setExpanded}
trigger={(
<div className="plan-execution-trigger-body">
<div>
<span>{execution.status === 'interrupted' ? '计划已中断' : '正在执行计划'}</span>
<strong>{proposal?.title ?? '已批准计划'}</strong>
<span>{execution.status === 'interrupted' ? copy.interrupted : copy.running}</span>
<strong>{proposal?.title ?? copy.approvedPlan}</strong>
</div>
<span className="plan-execution-summary">
<span className="plan-execution-count">{completedCount}/{execution.steps.length} 步</span>
<span className="plan-execution-count">{copy.stepCount(completedCount, execution.steps.length)}</span>
</span>
</div>
)}
Expand All @@ -281,8 +285,8 @@ export function PlanExecutionPanel(props: {
className="plan-execution-step-marker"
data-status={step.status}
role="img"
aria-label={executionStepStatusLabel(step.status)}
title={executionStepStatusLabel(step.status)}
aria-label={executionStepStatusLabel(step.status, copy)}
title={executionStepStatusLabel(step.status, copy)}
>
{executionStepMark(step.status)}
</span>
Expand All @@ -297,17 +301,17 @@ export function PlanExecutionPanel(props: {
size="sm"
isDisabled={planMode.pending}
onClick={() => void planMode.resume(execution.executionId)}
label="恢复执行"
label={copy.resume}
/>
<UiButton
variant="destructive"
size="sm"
isDisabled={planMode.pending}
onClick={() => void planMode.abandon(
execution.executionId,
proposal?.title ?? '已批准计划',
proposal?.title ?? copy.approvedPlan,
)}
label="放弃计划"
label={copy.abandon}
/>
</div>
)}
Expand All @@ -328,10 +332,11 @@ function isPlanToolResult(event: SessionEvent): boolean {
|| kind === 'plan_execution_cancelled';
}

function proposalStatusLabel(status: PlanProposal['status']): string {
if (status === 'approved') return '已批准';
if (status === 'stale') return '已过期';
return '等待确认';
function proposalStatusLabel(
status: PlanProposal['status'],
copy: PlanModeCopy['proposal'],
): string {
return copy.statuses[status];
}

/* #1879: the status pill is an Astryx `Badge`. Only `approved` is a semantic
Expand All @@ -344,11 +349,11 @@ function proposalStatusVariant(status: PlanProposal['status']): BadgeVariant {
return status === 'approved' ? 'green' : 'neutral';
}

function executionStepStatusLabel(status: PlanExecutionStep['status']): string {
if (status === 'completed') return '已完成';
if (status === 'in_progress') return '正在执行';
if (status === 'skipped') return '已跳过';
return '未开始';
function executionStepStatusLabel(
status: PlanExecutionStep['status'],
copy: PlanModeCopy['execution'],
): string {
return copy.stepStatuses[status];
}

function executionStepMark(status: PlanExecutionStep['status']): string {
Expand Down
Loading