Skip to content

Commit 32c689f

Browse files
committed
feat: persist channel routing mode immediately
1 parent 559a354 commit 32c689f

5 files changed

Lines changed: 118 additions & 84 deletions

File tree

frontend/src/features/channel-routing/components/ChannelRoutingWorkbench.tsx

Lines changed: 34 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ import {
55
History,
66
Play,
77
RefreshCw,
8-
Save,
98
ShieldCheck,
109
Shuffle,
11-
SlidersHorizontal,
1210
Split,
1311
X,
1412
Zap,
@@ -45,7 +43,6 @@ interface ChannelRoutingWorkbenchProps {
4543
onModeChange: (mode: ChannelRouteMode) => void;
4644
onShadowEnabledChange: (enabled: boolean) => void;
4745
onShadowModeChange: (mode: ChannelRouteMode) => void;
48-
onSave: () => void;
4946
onExplain: () => void;
5047
onRefreshEvents?: () => void;
5148
}
@@ -76,7 +73,6 @@ export default function ChannelRoutingWorkbench({
7673
onModeChange,
7774
onShadowEnabledChange,
7875
onShadowModeChange,
79-
onSave,
8076
onExplain,
8177
onRefreshEvents,
8278
}: ChannelRoutingWorkbenchProps) {
@@ -85,7 +81,6 @@ export default function ChannelRoutingWorkbench({
8581
const legacyMask = buildLegacyRoutingMaskPanel();
8682
const eventSummaries = routeEvents.slice(0, 5).map((event) => buildChannelRouteAuditEventSummary(event));
8783
const hasExplain = explainView.hasExplain;
88-
const routeModeLabel = resolveRouteModeLabel(config.routeMode);
8984
const shadowPanelLabel = config.shadowEnabled ? (hasExplain ? explainView.shadowLabel : '开启') : '关闭';
9085
const shadowPanelMeta = config.shadowEnabled && hasExplain ? explainView.shadowMeta : '';
9186
const participantRows = buildChannelRoutingParticipantRows(config, accounts);
@@ -114,53 +109,34 @@ export default function ChannelRoutingWorkbench({
114109
aria-label={`${channel} 请求模式`}
115110
className="overflow-hidden border-2 border-[var(--border-color)] bg-[var(--bg-main)]"
116111
>
117-
<header className="flex flex-wrap items-center justify-between gap-3 p-4">
118-
<div className="flex min-w-0 items-center gap-2">
119-
<Zap className="h-4 w-4 shrink-0 text-[var(--text-secondary)]" strokeWidth={3} />
120-
<h2 className="min-w-0 text-[length:var(--font-size-ui-lg)] font-black leading-5 tracking-[0] text-[var(--text-primary)] sm:text-[length:var(--font-size-heading-sm)] sm:leading-normal">
121-
请求模式
122-
</h2>
123-
<button
124-
type="button"
125-
onClick={() => setHelpOpen(true)}
126-
aria-label="查看请求模式说明"
127-
title="查看请求模式说明"
128-
aria-pressed={helpOpen}
129-
className={`flex h-8 w-8 shrink-0 items-center justify-center border-2 transition-colors active:scale-95 ${
130-
helpOpen
131-
? 'border-[var(--text-primary)] bg-[var(--text-primary)] text-[var(--bg-main)]'
132-
: 'border-[var(--border-color)] bg-[var(--bg-main)] text-[var(--text-primary)] [@media(hover:hover)]:hover:border-[var(--text-primary)]'
133-
}`}
134-
>
135-
<CircleHelp className="h-4 w-4" strokeWidth={4} />
136-
</button>
137-
{preview ? (
138-
<span className="font-mono text-[length:var(--font-size-ui-2xs)] font-black uppercase tracking-wide text-[var(--text-muted)]">
139-
预览
140-
</span>
141-
) : null}
142-
</div>
143-
<button
144-
type="button"
145-
onClick={onSave}
146-
disabled={disabled || saving}
147-
className="btn-swiss flex min-h-10 items-center gap-2 !px-3 !py-2 !text-[length:var(--font-size-ui-sm)]"
148-
>
149-
<Save className="h-3.5 w-3.5" strokeWidth={4} />
150-
{saving ? '保存中' : '保存'}
151-
</button>
152-
</header>
153-
154-
<div className="border-t-2 border-[var(--border-color)]">
155-
<section className="min-w-0 p-4">
156-
<div className="flex flex-wrap items-center justify-between gap-2">
157-
<SectionHeading icon={SlidersHorizontal} label="当前模式" />
158-
<span className="font-mono text-[length:var(--font-size-ui-2xs)] font-black uppercase tracking-wide text-[var(--text-muted)]">
159-
{routeModeLabel}
160-
</span>
112+
<header className="p-4">
113+
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
114+
<div className="flex min-w-0 items-center gap-2">
115+
<Zap className="h-4 w-4 shrink-0 text-[var(--text-secondary)]" strokeWidth={3} />
116+
<h2 className="min-w-0 text-[length:var(--font-size-ui-lg)] font-black leading-5 tracking-[0] text-[var(--text-primary)] sm:text-[length:var(--font-size-heading-sm)] sm:leading-normal">
117+
请求模式
118+
</h2>
119+
<button
120+
type="button"
121+
onClick={() => setHelpOpen(true)}
122+
aria-label="查看请求模式说明"
123+
title="查看请求模式说明"
124+
aria-pressed={helpOpen}
125+
className={`flex h-8 w-8 shrink-0 items-center justify-center border-2 transition-colors active:scale-95 ${
126+
helpOpen
127+
? 'border-[var(--text-primary)] bg-[var(--text-primary)] text-[var(--bg-main)]'
128+
: 'border-[var(--border-color)] bg-[var(--bg-main)] text-[var(--text-primary)] [@media(hover:hover)]:hover:border-[var(--text-primary)]'
129+
}`}
130+
>
131+
<CircleHelp className="h-4 w-4" strokeWidth={4} />
132+
</button>
133+
{preview ? (
134+
<span className="font-mono text-[length:var(--font-size-ui-2xs)] font-black uppercase tracking-wide text-[var(--text-muted)]">
135+
预览
136+
</span>
137+
) : null}
161138
</div>
162-
163-
<div className="mt-3 grid gap-2 sm:grid-cols-2">
139+
<div className="grid min-w-0 flex-1 gap-2 sm:max-w-[28rem] sm:flex-none sm:grid-cols-2">
164140
{routeModes.map((item) => (
165141
<StrategyButton
166142
key={item.mode}
@@ -169,13 +145,15 @@ export default function ChannelRoutingWorkbench({
169145
label={item.label}
170146
cue={item.cue}
171147
active={config.routeMode === item.mode}
172-
disabled={disabled}
148+
disabled={disabled || saving}
173149
onModeChange={onModeChange}
174150
/>
175151
))}
176152
</div>
177-
</section>
153+
</div>
154+
</header>
178155

156+
<div className="border-t-2 border-[var(--border-color)]">
179157
<details className="group/participants min-w-0 border-t-2 border-[var(--border-color)] p-4">
180158
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 [&::-webkit-details-marker]:hidden">
181159
<SectionHeading icon={Split} label="参与账号" />
@@ -381,14 +359,14 @@ function StrategyButton({
381359
onClick={() => onModeChange(mode)}
382360
disabled={disabled}
383361
aria-pressed={active}
384-
className={`grid min-h-14 grid-cols-[1.75rem_minmax(0,1fr)_auto] items-center gap-3 border-2 px-3 py-2 text-left transition-colors active:scale-[0.98] ${
362+
className={`grid min-h-10 grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-2 border-2 px-3 py-2 text-left transition-colors active:scale-[0.98] ${
385363
active
386364
? 'border-[var(--text-primary)] bg-[var(--text-primary)] text-[var(--bg-main)]'
387365
: 'border-[var(--border-color)] bg-[var(--bg-main)] text-[var(--text-primary)] [@media(hover:hover)]:hover:border-[var(--text-primary)]'
388366
}`}
389367
>
390-
<Icon className="h-4 w-4 shrink-0" strokeWidth={4} />
391-
<span className="min-w-0 text-[length:var(--font-size-ui-md)] font-black">{label}</span>
368+
<Icon className="h-3.5 w-3.5 shrink-0" strokeWidth={4} />
369+
<span className="min-w-0 truncate text-[length:var(--font-size-ui-sm)] font-black">{label}</span>
392370
<span
393371
className={`min-w-0 truncate font-mono text-[length:var(--font-size-ui-2xs)] font-black uppercase tracking-wide ${
394372
active ? 'text-[var(--bg-main)]/75' : 'text-[var(--text-muted)]'

frontend/src/features/channel-routing/tests/channelRouting.test.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,3 +441,14 @@ test('ChannelRoutingWorkbench opens route mode help as a modal next to route mod
441441
assert.doesNotMatch(source, //);
442442
assert.doesNotMatch(source, /view === 'help'/);
443443
});
444+
445+
test('ChannelRoutingWorkbench keeps route mode toggles in the header and removes the save button', async () => {
446+
const source = await readFile(new URL('../components/ChannelRoutingWorkbench.tsx', import.meta.url), 'utf8');
447+
448+
assert.match(source, /routeModes\.map/);
449+
assert.match(source, /onModeChange\(mode\)/);
450+
assert.match(source, /<header className="p-4">/);
451+
assert.match(source, /grid min-w-0 flex-1 gap-2 sm:max-w-\[28rem\] sm:flex-none sm:grid-cols-2/);
452+
assert.doesNotMatch(source, /\bonSave\b/);
453+
assert.doesNotMatch(source, /\bSave\b/);
454+
});

frontend/src/features/claude-code/ClaudeCodeAccountListFeature.tsx

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -385,15 +385,17 @@ export default function ClaudeCodeAccountListFeature({ sidecarStatus }: ClaudeCo
385385
setDetailRowID(null);
386386
}
387387

388-
async function saveOrder() {
389-
if (!ready || !orderDirty || saving) {
388+
async function persistChannelRoutingConfig(
389+
nextConfig: ChannelRoutingConfig,
390+
options: { reloadAfterSave?: boolean } = {},
391+
) {
392+
if (!ready || saving) {
390393
return;
391394
}
392395

393396
setSaving(true);
394397
setMessage('');
395398
try {
396-
const nextConfig = withCurrentChannelOrder(channelConfig, orderedRows.map((row) => row.id));
397399
if (!browserMode) {
398400
await trackRequest('SaveChannelRoutingConfig', { channel: 'claude', mode: nextConfig.routeMode }, () =>
399401
SaveChannelRoutingConfig(main.ChannelRoutingConfig.createFrom(nextConfig)),
@@ -403,8 +405,10 @@ export default function ClaudeCodeAccountListFeature({ sidecarStatus }: ClaudeCo
403405
setOrderDirty(false);
404406
if (browserMode) {
405407
setMessage(t('claude_code.account_list_preview_saved'));
406-
} else {
408+
} else if (options.reloadAfterSave) {
407409
await reload(t('claude_code.account_list_saved'));
410+
} else {
411+
setMessage('');
408412
}
409413
} catch (error) {
410414
console.error(error);
@@ -414,6 +418,15 @@ export default function ClaudeCodeAccountListFeature({ sidecarStatus }: ClaudeCo
414418
}
415419
}
416420

421+
async function saveOrder() {
422+
if (!ready || !orderDirty || saving) {
423+
return;
424+
}
425+
426+
const nextConfig = withCurrentChannelOrder(channelConfig, orderedRows.map((row) => row.id));
427+
await persistChannelRoutingConfig(nextConfig, { reloadAfterSave: !browserMode });
428+
}
429+
417430
useEffect(() => {
418431
if (!autoSaveOrderRequested || draggedID || saving) {
419432
return;
@@ -427,24 +440,30 @@ export default function ClaudeCodeAccountListFeature({ sidecarStatus }: ClaudeCo
427440
}, [autoSaveOrderRequested, channelConfig, draggedID, orderDirty, orderedRows, saving]);
428441

429442
function updateChannelMode(mode: ChannelRouteMode) {
430-
setChannelConfig((prev) => updateChannelRoutingConfig(prev, { routeMode: mode }));
431-
setOrderDirty(true);
443+
const nextConfig = withCurrentChannelOrder(
444+
updateChannelRoutingConfig(channelConfig, { routeMode: mode }),
445+
orderedRows.map((row) => row.id),
446+
);
432447
setChannelExplain(null);
433-
setMessage(t('claude_code.account_list_unsaved'));
448+
void persistChannelRoutingConfig(nextConfig);
434449
}
435450

436451
function updateShadowEnabled(enabled: boolean) {
437-
setChannelConfig((prev) => updateChannelRoutingConfig(prev, { shadowEnabled: enabled }));
438-
setOrderDirty(true);
452+
const nextConfig = withCurrentChannelOrder(
453+
updateChannelRoutingConfig(channelConfig, { shadowEnabled: enabled }),
454+
orderedRows.map((row) => row.id),
455+
);
439456
setChannelExplain(null);
440-
setMessage(t('claude_code.account_list_unsaved'));
457+
void persistChannelRoutingConfig(nextConfig);
441458
}
442459

443460
function updateShadowMode(mode: ChannelRouteMode) {
444-
setChannelConfig((prev) => updateChannelRoutingConfig(prev, { shadowRouteMode: mode }));
445-
setOrderDirty(true);
461+
const nextConfig = withCurrentChannelOrder(
462+
updateChannelRoutingConfig(channelConfig, { shadowRouteMode: mode }),
463+
orderedRows.map((row) => row.id),
464+
);
446465
setChannelExplain(null);
447-
setMessage(t('claude_code.account_list_unsaved'));
466+
void persistChannelRoutingConfig(nextConfig);
448467
}
449468

450469
async function runChannelExplain() {
@@ -763,7 +782,6 @@ export default function ClaudeCodeAccountListFeature({ sidecarStatus }: ClaudeCo
763782
onModeChange={updateChannelMode}
764783
onShadowEnabledChange={updateShadowEnabled}
765784
onShadowModeChange={updateShadowMode}
766-
onSave={() => void saveOrder()}
767785
onExplain={() => void runChannelExplain()}
768786
onRefreshEvents={() => void loadChannelRouteEvents()}
769787
/>

frontend/src/features/codex/CodexAccountListFeature.tsx

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -518,15 +518,17 @@ export default function CodexAccountListFeature({ sidecarStatus }: CodexAccountL
518518
};
519519
}, []);
520520

521-
async function saveOrder() {
522-
if (!ready || !orderChanged || saving) {
521+
async function persistChannelRoutingConfig(
522+
nextConfig: ChannelRoutingConfig,
523+
options: { reloadAfterSave?: boolean } = {},
524+
) {
525+
if (!ready || saving) {
523526
return;
524527
}
525528

526529
setSaving(true);
527530
setMessage('');
528531
try {
529-
const nextConfig = withCurrentChannelOrder(channelConfig, orderedRows.map((row) => row.id));
530532
if (!browserMode) {
531533
await trackRequest('SaveChannelRoutingConfig', { channel: 'codex', mode: nextConfig.routeMode }, () =>
532534
SaveChannelRoutingConfig(main.ChannelRoutingConfig.createFrom(nextConfig)),
@@ -536,8 +538,10 @@ export default function CodexAccountListFeature({ sidecarStatus }: CodexAccountL
536538
setOrderDirty(false);
537539
if (browserMode) {
538540
setMessage(t('codex.account_list_preview_saved'));
539-
} else {
541+
} else if (options.reloadAfterSave) {
540542
await reload(t('codex.account_list_saved'));
543+
} else {
544+
setMessage('');
541545
}
542546
} catch (error) {
543547
console.error(error);
@@ -547,6 +551,15 @@ export default function CodexAccountListFeature({ sidecarStatus }: CodexAccountL
547551
}
548552
}
549553

554+
async function saveOrder() {
555+
if (!ready || !orderChanged || saving) {
556+
return;
557+
}
558+
559+
const nextConfig = withCurrentChannelOrder(channelConfig, orderedRows.map((row) => row.id));
560+
await persistChannelRoutingConfig(nextConfig, { reloadAfterSave: !browserMode });
561+
}
562+
550563
useEffect(() => {
551564
if (!autoSaveOrderRequested || draggedID || saving) {
552565
return;
@@ -560,24 +573,30 @@ export default function CodexAccountListFeature({ sidecarStatus }: CodexAccountL
560573
}, [autoSaveOrderRequested, channelConfig, draggedID, orderChanged, orderedRows, saving]);
561574

562575
function updateChannelMode(mode: ChannelRouteMode) {
563-
setChannelConfig((prev) => updateChannelRoutingConfig(prev, { routeMode: mode }));
564-
setOrderDirty(true);
576+
const nextConfig = withCurrentChannelOrder(
577+
updateChannelRoutingConfig(channelConfig, { routeMode: mode }),
578+
orderedRows.map((row) => row.id),
579+
);
565580
setChannelExplain(null);
566-
setMessage(t('codex.account_list_unsaved'));
581+
void persistChannelRoutingConfig(nextConfig);
567582
}
568583

569584
function updateShadowEnabled(enabled: boolean) {
570-
setChannelConfig((prev) => updateChannelRoutingConfig(prev, { shadowEnabled: enabled }));
571-
setOrderDirty(true);
585+
const nextConfig = withCurrentChannelOrder(
586+
updateChannelRoutingConfig(channelConfig, { shadowEnabled: enabled }),
587+
orderedRows.map((row) => row.id),
588+
);
572589
setChannelExplain(null);
573-
setMessage(t('codex.account_list_unsaved'));
590+
void persistChannelRoutingConfig(nextConfig);
574591
}
575592

576593
function updateShadowMode(mode: ChannelRouteMode) {
577-
setChannelConfig((prev) => updateChannelRoutingConfig(prev, { shadowRouteMode: mode }));
578-
setOrderDirty(true);
594+
const nextConfig = withCurrentChannelOrder(
595+
updateChannelRoutingConfig(channelConfig, { shadowRouteMode: mode }),
596+
orderedRows.map((row) => row.id),
597+
);
579598
setChannelExplain(null);
580-
setMessage(t('codex.account_list_unsaved'));
599+
void persistChannelRoutingConfig(nextConfig);
581600
}
582601

583602
async function runChannelExplain() {
@@ -896,7 +915,6 @@ export default function CodexAccountListFeature({ sidecarStatus }: CodexAccountL
896915
onModeChange={updateChannelMode}
897916
onShadowEnabledChange={updateShadowEnabled}
898917
onShadowModeChange={updateShadowMode}
899-
onSave={() => void saveOrder()}
900918
onExplain={() => void runChannelExplain()}
901919
onRefreshEvents={() => void loadChannelRouteEvents()}
902920
/>

frontend/src/features/codex/codexAccountList.test.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,15 @@ test('getCodexAccountOrderGridClass keeps list single-column and card modes adap
573573
assert.doesNotMatch(getCodexAccountOrderGridClass('full'), /xl:grid-cols-3/);
574574
});
575575

576+
test('Codex account list switches routing mode through immediate config persistence', async () => {
577+
const source = await readFile(new URL('./CodexAccountListFeature.tsx', import.meta.url), 'utf8');
578+
579+
assert.match(source, /async function persistChannelRoutingConfig/);
580+
assert.match(source, /updateChannelMode\(mode: ChannelRouteMode\)/);
581+
assert.match(source, /void persistChannelRoutingConfig\(nextConfig/);
582+
assert.doesNotMatch(source, /onSave=\{\(\) => void saveOrder\(\)\}/);
583+
});
584+
576585
test('Codex account order cards reuse the account attribution card and keep custom controls in the footer', async () => {
577586
const source = await readFile(new URL('./components/CodexAccountOrderRow.tsx', import.meta.url), 'utf8');
578587

0 commit comments

Comments
 (0)