From 422ad331bb7920fb2d2944fd82cfb9d1c65e8e2f Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 01:59:38 +0900 Subject: [PATCH] reuse status dashboard message and simplify settings actions --- apps/dashboard/src/MoaSettingsPanel.tsx | 30 +----- apps/dashboard/src/SettingsPanel.test.ts | 2 + apps/dashboard/src/SettingsPanel.tsx | 113 +++++++++-------------- apps/dashboard/src/styles.css | 33 +++++++ src/status-dashboard.test.ts | 30 ++++++ src/status-dashboard.ts | 57 +++++++++++- src/unified-dashboard.test.ts | 27 ++++++ src/unified-dashboard.ts | 41 +++++++- 8 files changed, 230 insertions(+), 103 deletions(-) create mode 100644 src/status-dashboard.test.ts diff --git a/apps/dashboard/src/MoaSettingsPanel.tsx b/apps/dashboard/src/MoaSettingsPanel.tsx index 9b473e0..18428ea 100644 --- a/apps/dashboard/src/MoaSettingsPanel.tsx +++ b/apps/dashboard/src/MoaSettingsPanel.tsx @@ -9,11 +9,7 @@ import { updateMoaSettings, } from './api'; -export function MoaSettingsPanel({ - onRestartStack, -}: { - onRestartStack: () => void; -}) { +export function MoaSettingsPanel() { const [config, setConfig] = useState(null); const [draft, setDraft] = useState(null); const [apiKeys, setApiKeys] = useState>({}); @@ -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, ) => void; - onRestartStack: () => void; onSave: () => Promise; onTest: (name: string) => Promise; 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; savedAt: number | null; }) { @@ -275,29 +265,13 @@ function MoaSettingsActions({ onClick={() => void onSave()} type="button" > - {busy ? '저장 중…' : '저장'} + {busy ? '저장 중…' : 'MoA 저장'} {savedAt && !dirty ? ( 저장됨. 적용하려면 스택 재시작 필요. ) : null} - ); } diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts index 42ec421..db03e42 100644 --- a/apps/dashboard/src/SettingsPanel.test.ts +++ b/apps/dashboard/src/SettingsPanel.test.ts @@ -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); }); }); diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index eed5ddb..2028d3e 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -79,20 +79,51 @@ export function SettingsPanel({ - + - + + + - + - + ); } -function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) { +function SettingsApplyBar({ onRestartStack }: { onRestartStack: () => void }) { + return ( +
+
+ 변경 적용 + + 모델, MoA, Codex 실험 기능, 계정 변경은 저장 후 스택 재시작으로 + 적용됩니다. + +
+ +
+ ); +} + +function ModelSettings() { const [config, setConfig] = useState(null); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); @@ -193,29 +224,13 @@ function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) { onClick={() => void save()} type="button" > - {busy ? '저장 중…' : '저장'} + {busy ? '저장 중…' : '모델 저장'} {savedAt && !dirty ? ( 저장됨. 적용하려면 스택 재시작 필요. ) : null} - )} @@ -307,11 +322,7 @@ function FastModeSettings() { ); } -function CodexFeatureSettings({ - onRestartStack, -}: { - onRestartStack: () => void; -}) { +function CodexFeatureSettings() { const [state, setState] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -376,29 +387,11 @@ function CodexFeatureSettings({ type="checkbox" /> -
- {savedAt ? ( - - 저장됨. 적용하려면 스택 재시작 필요. - - ) : null} - -
+ {savedAt ? ( + + 저장됨. 적용하려면 상단의 스택 재시작을 눌러 주세요. + + ) : null} )} @@ -436,7 +429,7 @@ function formatExpiry( }; } -function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { +function AccountSettings() { const [data, setData] = useState(null); const [busy, setBusy] = useState(false); const [perRowBusy, setPerRowBusy] = useState(null); @@ -561,24 +554,6 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { onSwitch={(index) => void handleSwitchCodex(index)} perRowBusy={perRowBusy} /> -
- -
); } diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 27b18ef..4ec0fcf 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -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; diff --git a/src/status-dashboard.test.ts b/src/status-dashboard.test.ts new file mode 100644 index 0000000..d48372d --- /dev/null +++ b/src/status-dashboard.test.ts @@ -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(); + }); +}); diff --git a/src/status-dashboard.ts b/src/status-dashboard.ts index 59d77ba..08b057b 100644 --- a/src/status-dashboard.ts +++ b/src/status-dashboard.ts @@ -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( + 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 }); diff --git a/src/unified-dashboard.test.ts b/src/unified-dashboard.test.ts index 6e77084..fbc7f0d 100644 --- a/src/unified-dashboard.test.ts +++ b/src/unified-dashboard.test.ts @@ -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', diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 86113ba..9aac39f 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -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 { @@ -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) { - await channel.editMessage(statusJid, statusMessageId, content); - } else if (channel.sendAndTrack) { + try { + await channel.editMessage(statusJid, statusMessageId, content); + 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(