Reuse status dashboard message and simplify settings actions

This commit is contained in:
Eyejoker
2026-05-02 02:06:20 +09:00
committed by GitHub
parent 7576bcd3ff
commit 490ae7d324
8 changed files with 230 additions and 103 deletions

View File

@@ -9,11 +9,7 @@ import {
updateMoaSettings,
} from './api';
export function MoaSettingsPanel({
onRestartStack,
}: {
onRestartStack: () => void;
}) {
export function MoaSettingsPanel() {
const [config, setConfig] = useState<MoaSettingsSnapshot | null>(null);
const [draft, setDraft] = useState<MoaSettingsSnapshot | null>(null);
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
@@ -135,7 +131,6 @@ export function MoaSettingsPanel({
setApiKeys((prev) => ({ ...prev, [name]: value }))
}
onModelChange={setModel}
onRestartStack={onRestartStack}
onSave={save}
onTest={testModel}
onToggle={() =>
@@ -158,7 +153,6 @@ function MoaSettingsContent({
draft,
onApiKeyChange,
onModelChange,
onRestartStack,
onSave,
onTest,
onToggle,
@@ -174,7 +168,6 @@ function MoaSettingsContent({
name: string,
patch: Partial<MoaModelSettingsSnapshot>,
) => void;
onRestartStack: () => void;
onSave: () => Promise<void>;
onTest: (name: string) => Promise<void>;
onToggle: () => void;
@@ -206,7 +199,6 @@ function MoaSettingsContent({
busy={busy}
checking={checking}
dirty={dirty}
onRestartStack={onRestartStack}
onSave={onSave}
savedAt={savedAt}
/>
@@ -256,14 +248,12 @@ function MoaSettingsActions({
busy,
checking,
dirty,
onRestartStack,
onSave,
savedAt,
}: {
busy: boolean;
checking: string | null;
dirty: boolean;
onRestartStack: () => void;
onSave: () => Promise<void>;
savedAt: number | null;
}) {
@@ -275,29 +265,13 @@ function MoaSettingsActions({
onClick={() => void onSave()}
type="button"
>
{busy ? '저장 중…' : '저장'}
{busy ? '저장 중…' : 'MoA 저장'}
</button>
{savedAt && !dirty ? (
<small className="settings-hint">
. .
</small>
) : null}
<button
className="settings-restart"
disabled={busy || checking !== null}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
</button>
</div>
);
}

View File

@@ -35,10 +35,12 @@ describe('SettingsPanel', () => {
expect(html).toContain('MoA 참조 모델');
expect(html).toContain('패스트 모드');
expect(html).toContain('Codex 실험 기능');
expect(html).toContain('변경 적용');
expect(html).toContain('불러오는 중');
expect(html).toContain('Claude');
expect(html).toContain('계정');
expect(html).toContain('전체 갱신');
expect(html).toContain('스택 재시작');
expect(html.match(/class="settings-restart"/g)).toHaveLength(1);
});
});

View File

@@ -79,20 +79,51 @@ export function SettingsPanel({
</label>
</section>
<ModelSettings onRestartStack={onRestartStack} />
<SettingsApplyBar onRestartStack={onRestartStack} />
<MoaSettingsPanel onRestartStack={onRestartStack} />
<ModelSettings />
<MoaSettingsPanel />
<FastModeSettings />
<CodexFeatureSettings onRestartStack={onRestartStack} />
<CodexFeatureSettings />
<AccountSettings onRestartStack={onRestartStack} />
<AccountSettings />
</div>
);
}
function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
function SettingsApplyBar({ onRestartStack }: { onRestartStack: () => void }) {
return (
<section className="settings-apply-bar" aria-label="변경 적용">
<div>
<strong> </strong>
<small>
, MoA, Codex ,
.
</small>
</div>
<button
className="settings-restart"
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
</button>
</section>
);
}
function ModelSettings() {
const [config, setConfig] = useState<ModelConfigSnapshot | null>(null);
const [draft, setDraft] = useState<ModelConfigSnapshot | null>(null);
const [busy, setBusy] = useState(false);
@@ -193,29 +224,13 @@ function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
onClick={() => void save()}
type="button"
>
{busy ? '저장 중…' : '저장'}
{busy ? '저장 중…' : '모델 저장'}
</button>
{savedAt && !dirty ? (
<small className="settings-hint">
. .
</small>
) : null}
<button
className="settings-restart"
disabled={busy}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
</button>
</div>
</>
)}
@@ -307,11 +322,7 @@ function FastModeSettings() {
);
}
function CodexFeatureSettings({
onRestartStack,
}: {
onRestartStack: () => void;
}) {
function CodexFeatureSettings() {
const [state, setState] = useState<CodexFeatureSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -376,29 +387,11 @@ function CodexFeatureSettings({
type="checkbox"
/>
</label>
<div className="settings-actions">
{savedAt ? (
<small className="settings-hint">
. .
. .
</small>
) : null}
<button
className="settings-restart"
disabled={busy}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
</button>
</div>
</>
)}
</section>
@@ -436,7 +429,7 @@ function formatExpiry(
};
}
function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
function AccountSettings() {
const [data, setData] = useState<AccountData | null>(null);
const [busy, setBusy] = useState(false);
const [perRowBusy, setPerRowBusy] = useState<string | null>(null);
@@ -561,24 +554,6 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
onSwitch={(index) => void handleSwitchCodex(index)}
perRowBusy={perRowBusy}
/>
<div className="settings-actions">
<button
className="settings-restart"
disabled={busy}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
( )
</button>
</div>
</section>
);
}

View File

@@ -696,6 +696,39 @@ button:disabled {
letter-spacing: 0.05em;
}
.settings-apply-bar {
position: sticky;
top: 72px;
z-index: 5;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid rgba(214, 130, 88, 0.32);
border-radius: 10px;
background:
linear-gradient(135deg, rgba(214, 130, 88, 0.14), transparent 55%),
rgba(19, 24, 22, 0.94);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.2);
}
.settings-apply-bar > div {
display: grid;
gap: 3px;
min-width: 0;
}
.settings-apply-bar strong {
color: var(--bg-ink);
font-size: 13px;
}
.settings-apply-bar small {
color: var(--muted);
font-size: 11px;
}
.settings-row-inline {
grid-template-columns: 80px minmax(0, 1fr) 100px;
display: grid;

View File

@@ -0,0 +1,30 @@
import fs from 'fs';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { CACHE_DIR } from './config.js';
import {
readDashboardStatusMessageId,
writeDashboardStatusMessageId,
} from './status-dashboard.js';
afterEach(() => {
const messagesDir = path.join(CACHE_DIR, 'status-dashboard', 'messages');
fs.rmSync(path.join(messagesDir, 'test-status-channel.json'), {
force: true,
});
});
describe('dashboard status message id state', () => {
it('persists the tracked Discord status message id across restarts', async () => {
expect(readDashboardStatusMessageId('test-status-channel')).toBeNull();
writeDashboardStatusMessageId('test-status-channel', '1499810000000000000');
expect(readDashboardStatusMessageId('test-status-channel')).toBe(
'1499810000000000000',
);
expect(readDashboardStatusMessageId('other-channel')).toBeNull();
});
});

View File

@@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path';
import { CACHE_DIR } from './config.js';
import { writeJsonFile } from './utils.js';
import { readJsonFile, writeJsonFile } from './utils.js';
import type { GroupStatus } from './group-queue.js';
import type { AgentType } from './types.js';
@@ -37,6 +37,61 @@ export interface StatusSnapshot {
}
const STATUS_SNAPSHOT_DIR = path.join(CACHE_DIR, 'status-dashboard');
const STATUS_MESSAGE_STATE_DIR = path.join(STATUS_SNAPSHOT_DIR, 'messages');
interface StatusMessageState {
messageId: string;
statusChannelId: string;
updatedAt: string;
}
function sanitizeStatusChannelId(statusChannelId: string): string {
return statusChannelId.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function getStatusMessageStatePath(statusChannelId: string): string {
return path.join(
STATUS_MESSAGE_STATE_DIR,
`${sanitizeStatusChannelId(statusChannelId)}.json`,
);
}
export function readDashboardStatusMessageId(
statusChannelId: string,
): string | null {
if (!statusChannelId) return null;
const parsed = readJsonFile<StatusMessageState>(
getStatusMessageStatePath(statusChannelId),
);
if (
parsed?.statusChannelId !== statusChannelId ||
typeof parsed.messageId !== 'string' ||
parsed.messageId.trim() === ''
) {
return null;
}
return parsed.messageId;
}
export function writeDashboardStatusMessageId(
statusChannelId: string,
messageId: string,
): void {
if (!statusChannelId || !messageId) return;
fs.mkdirSync(STATUS_MESSAGE_STATE_DIR, { recursive: true });
const targetPath = getStatusMessageStatePath(statusChannelId);
const tempPath = `${targetPath}.tmp`;
writeJsonFile(
tempPath,
{
messageId,
statusChannelId,
updatedAt: new Date().toISOString(),
} satisfies StatusMessageState,
true,
);
fs.renameSync(tempPath, targetPath);
}
export function writeStatusSnapshot(snapshot: StatusSnapshot): void {
fs.mkdirSync(STATUS_SNAPSHOT_DIR, { recursive: true });

View File

@@ -5,6 +5,7 @@ import {
buildWebUsageRowsForSnapshot,
formatStatusHeader,
renderUsageTable,
shouldPurgeDashboardChannelOnStart,
summarizeWatcherTasks,
} from './unified-dashboard.js';
@@ -61,6 +62,32 @@ describe('formatStatusHeader', () => {
});
});
describe('shouldPurgeDashboardChannelOnStart', () => {
it('keeps the existing status message when a stored message id exists', () => {
expect(
shouldPurgeDashboardChannelOnStart({
purgeOnStart: true,
storedMessageId: 'status-message-1',
}),
).toBe(false);
});
it('purges only when explicitly requested and no stored message id exists', () => {
expect(
shouldPurgeDashboardChannelOnStart({
purgeOnStart: true,
storedMessageId: null,
}),
).toBe(true);
expect(
shouldPurgeDashboardChannelOnStart({
purgeOnStart: false,
storedMessageId: null,
}),
).toBe(false);
});
});
describe('renderUsageTable', () => {
const claudeRow: UsageRow = {
name: 'Claude pro',

View File

@@ -52,7 +52,9 @@ import type { GroupQueue } from './group-queue.js';
import { logger } from './logger.js';
import { isWatchCiTask } from './task-watch-status.js';
import {
readDashboardStatusMessageId,
readStatusSnapshots,
writeDashboardStatusMessageId,
writeStatusSnapshot,
} from './status-dashboard.js';
import type {
@@ -169,6 +171,13 @@ export async function purgeDashboardChannel(
}
}
export function shouldPurgeDashboardChannelOnStart(args: {
purgeOnStart?: boolean;
storedMessageId: string | null;
}): boolean {
return args.purgeOnStart === true && !args.storedMessageId;
}
async function refreshChannelMeta(
opts: UnifiedDashboardOptions,
): Promise<void> {
@@ -708,8 +717,17 @@ export async function startUnifiedDashboard(
const isRenderer = opts.serviceAgentType === 'claude-code';
const statusJid = `dc:${opts.statusChannelId}`;
if (isRenderer) {
statusMessageId = readDashboardStatusMessageId(opts.statusChannelId);
}
if (isRenderer && opts.purgeOnStart) {
if (
isRenderer &&
shouldPurgeDashboardChannelOnStart({
purgeOnStart: opts.purgeOnStart,
storedMessageId: statusMessageId,
})
) {
await purgeDashboardChannel(opts);
}
@@ -746,15 +764,28 @@ export async function startUnifiedDashboard(
},
'Dashboard content empty, skipping render',
);
statusMessageId = null;
return;
}
if (statusMessageId && channel.editMessage) {
try {
await channel.editMessage(statusJid, statusMessageId, content);
} else if (channel.sendAndTrack) {
writeDashboardStatusMessageId(opts.statusChannelId, statusMessageId);
} catch (err) {
logger.warn(
{ err, messageId: statusMessageId },
'Dashboard status message edit failed; sending a fresh tracked message',
);
statusMessageId = null;
}
}
if (!statusMessageId && channel.sendAndTrack) {
const id = await channel.sendAndTrack(statusJid, content);
if (id) statusMessageId = id;
if (id) {
statusMessageId = id;
writeDashboardStatusMessageId(opts.statusChannelId, id);
}
}
if (!dashboardUpdateLogged) {
logger.info(