refine dashboard settings and inbox UX

This commit is contained in:
Eyejoker
2026-05-02 14:22:49 +09:00
committed by GitHub
parent 7f77327041
commit fb32726ac0
9 changed files with 99 additions and 230 deletions

View File

@@ -85,22 +85,6 @@ const baseProps: ServicePanelProps = {
}, },
], ],
{ {
inbox: [
{
createdAt: '2026-04-28T04:10:00.000Z',
groupFolder: 'eyejokerdb',
groupKey: 'ci',
id: 'ci-1',
kind: 'ci-failure',
lastOccurredAt: '2026-04-28T04:12:00.000Z',
occurrences: 2,
occurredAt: '2026-04-28T04:10:00.000Z',
severity: 'error',
source: 'status-snapshot',
summary: 'CI failed',
title: 'CI failed',
},
],
operations: { operations: {
serviceRestarts: [ serviceRestarts: [
{ {
@@ -113,6 +97,13 @@ const baseProps: ServicePanelProps = {
}, },
], ],
}, },
tasks: {
active: 0,
completed: 0,
paused: 2,
total: 2,
watchers: { active: 0, completed: 0, paused: 2 },
},
}, },
), ),
serviceActionKey: null, serviceActionKey: null,

View File

@@ -67,11 +67,7 @@ export function ServicePanel({
}, },
{ pendingTasks: 0, pendingMessageRooms: 0 }, { pendingTasks: 0, pendingMessageRooms: 0 },
); );
const ciFailures = overview.inbox.reduce( const ciFailures = overview.tasks.watchers.paused;
(count, item) =>
item.kind === 'ci-failure' ? count + item.occurrences : count,
0,
);
const healthLevel: HealthLevel = const healthLevel: HealthLevel =
down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok'; down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok';
const affectedServices = serviceLevels.filter((item) => item.level !== 'ok'); const affectedServices = serviceLevels.filter((item) => item.level !== 'ok');

View File

@@ -21,7 +21,8 @@ describe('SettingsPanel', () => {
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps)); const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
expect(html).toContain('settings-panel'); expect(html).toContain('settings-panel');
expect(html).toContain('settings-hero'); expect(html).not.toContain('settings-hero');
expect(html).toContain('settings-sidebar');
expect(html).toContain('settings-nav'); expect(html).toContain('settings-nav');
expect(html).toContain(t.settings.nicknameLabel); expect(html).toContain(t.settings.nicknameLabel);
expect(html).toContain('value="Night Owl"'); expect(html).toContain('value="Night Owl"');
@@ -41,7 +42,9 @@ describe('SettingsPanel', () => {
expect(html).toContain('aria-controls="settings-codex"'); expect(html).toContain('aria-controls="settings-codex"');
expect(html).not.toContain('href="#settings-codex"'); expect(html).not.toContain('href="#settings-codex"');
expect(html).toContain('/goal'); expect(html).toContain('/goal');
expect(html).toContain('변경 적용'); expect(html).toContain('settings-apply-card');
expect(html).not.toContain('settings-apply-bar');
expect(html).toContain('저장 후 재시작');
expect(html).toContain('불러오는 중'); expect(html).toContain('불러오는 중');
expect(html).toContain('Claude'); expect(html).toContain('Claude');
expect(html).toContain('계정'); expect(html).toContain('계정');

View File

@@ -62,9 +62,11 @@ export function SettingsPanel({
}: SettingsPanelProps) { }: SettingsPanelProps) {
return ( return (
<div className="settings-panel"> <div className="settings-panel">
<SettingsHero />
<div className="settings-layout"> <div className="settings-layout">
<SettingsNav /> <aside className="settings-sidebar" aria-label="설정 탐색과 적용">
<SettingsNav />
<SettingsApplyCard onRestartStack={onRestartStack} />
</aside>
<main className="settings-content" aria-label="설정 항목"> <main className="settings-content" aria-label="설정 항목">
<section className="settings-section" id="settings-general"> <section className="settings-section" id="settings-general">
<SettingsSectionHeading <SettingsSectionHeading
@@ -109,8 +111,6 @@ export function SettingsPanel({
</div> </div>
</section> </section>
<SettingsApplyBar onRestartStack={onRestartStack} />
<ModelSettings /> <ModelSettings />
<MoaSettingsPanel /> <MoaSettingsPanel />
@@ -134,26 +134,6 @@ export function SettingsPanel({
); );
} }
function SettingsHero() {
return (
<header className="settings-hero">
<div>
<span className="settings-kicker">Operations console</span>
<h2></h2>
<p>
, MoA, Codex , .
.
</p>
</div>
<div className="settings-hero-badges" aria-label="주요 설정">
<span>Codex /goal</span>
<span>MoA</span>
<span>Accounts</span>
</div>
</header>
);
}
function SettingsNav() { function SettingsNav() {
const scrollToSection = (targetId: string) => { const scrollToSection = (targetId: string) => {
document document
@@ -197,16 +177,13 @@ function SettingsSectionHeading({
); );
} }
function SettingsApplyBar({ onRestartStack }: { onRestartStack: () => void }) { function SettingsApplyCard({ onRestartStack }: { onRestartStack: () => void }) {
return ( return (
<section className="settings-apply-bar" aria-label="변경 적용"> <section className="settings-apply-card" aria-label="변경 적용">
<div> <div>
<span className="settings-kicker">Apply changes</span> <span className="settings-kicker">Apply</span>
<strong> </strong> <strong> </strong>
<small> <small>, MoA, Codex, .</small>
, MoA, Codex ,
.
</small>
</div> </div>
<button <button
className="settings-restart" className="settings-restart"

View File

@@ -351,7 +351,7 @@ export const messages = {
heartbeat: '하트비트', heartbeat: '하트비트',
healthSignals: '운영 신호', healthSignals: '운영 신호',
inbox: 'Inbox', inbox: 'Inbox',
inboxQueue: 'Queue', inboxQueue: '처리할 액션',
usage: '사용량', usage: '사용량',
usageWindow: '남음 / 속도', usageWindow: '남음 / 속도',
rooms: '룸', rooms: '룸',
@@ -392,7 +392,7 @@ export const messages = {
}, },
}, },
inbox: { inbox: {
empty: 'Inbox 없음.', empty: '처리할 Inbox 액션 없음.',
cardsAria: 'Inbox 항목', cardsAria: 'Inbox 항목',
summary: 'Inbox 요약', summary: 'Inbox 요약',
total: '전체', total: '전체',
@@ -609,7 +609,7 @@ export const messages = {
heartbeat: 'Heartbeat', heartbeat: 'Heartbeat',
healthSignals: 'Signals', healthSignals: 'Signals',
inbox: 'Inbox', inbox: 'Inbox',
inboxQueue: 'Queue', inboxQueue: 'Action queue',
usage: 'Usage', usage: 'Usage',
usageWindow: 'Remaining / speed', usageWindow: 'Remaining / speed',
rooms: 'Rooms', rooms: 'Rooms',
@@ -650,7 +650,7 @@ export const messages = {
}, },
}, },
inbox: { inbox: {
empty: 'No queued work.', empty: 'No Inbox actions.',
cardsAria: 'Inbox items', cardsAria: 'Inbox items',
summary: 'Inbox summary', summary: 'Inbox summary',
total: 'Total', total: 'Total',
@@ -867,7 +867,7 @@ export const messages = {
heartbeat: '心跳', heartbeat: '心跳',
healthSignals: '运行信号', healthSignals: '运行信号',
inbox: '收件', inbox: '收件',
inboxQueue: '待处理', inboxQueue: '待处理操作',
usage: '用量', usage: '用量',
usageWindow: '剩余 / 速度', usageWindow: '剩余 / 速度',
rooms: '房间', rooms: '房间',
@@ -908,7 +908,7 @@ export const messages = {
}, },
}, },
inbox: { inbox: {
empty: '暂无收件。', empty: '暂无待处理收件操作。',
cardsAria: '收件项', cardsAria: '收件项',
summary: '收件摘要', summary: '收件摘要',
total: '全部', total: '全部',
@@ -1127,7 +1127,7 @@ export const messages = {
heartbeat: 'ハートビート', heartbeat: 'ハートビート',
healthSignals: '運用シグナル', healthSignals: '運用シグナル',
inbox: '受信', inbox: '受信',
inboxQueue: '対応待ち', inboxQueue: '対応アクション',
usage: '使用量', usage: '使用量',
usageWindow: '残量 / 速度', usageWindow: '残量 / 速度',
rooms: 'ルーム', rooms: 'ルーム',
@@ -1168,7 +1168,7 @@ export const messages = {
}, },
}, },
inbox: { inbox: {
empty: '受信なし。', empty: '対応する受信アクションはありません。',
cardsAria: '受信項目', cardsAria: '受信項目',
summary: '受信サマリー', summary: '受信サマリー',
total: '全体', total: '全体',

View File

@@ -628,65 +628,10 @@ button:disabled {
.settings-panel { .settings-panel {
display: grid; display: grid;
gap: 18px;
max-width: 1160px; max-width: 1160px;
width: 100%; width: 100%;
} }
.settings-hero {
position: relative;
overflow: hidden;
display: flex;
gap: 18px;
align-items: flex-end;
justify-content: space-between;
padding: 20px;
border: 1px solid rgba(214, 130, 88, 0.18);
border-radius: 18px;
background:
radial-gradient(
circle at 12% 0%,
rgba(214, 130, 88, 0.2),
transparent 28rem
),
linear-gradient(135deg, rgba(255, 255, 255, 0.055), transparent 52%),
var(--panel);
box-shadow: var(--card-shadow-strong);
}
.settings-hero::after {
content: '';
position: absolute;
right: -70px;
top: -90px;
width: 260px;
height: 260px;
border: 1px solid rgba(214, 130, 88, 0.2);
border-radius: 999px;
background: rgba(214, 130, 88, 0.04);
pointer-events: none;
}
.settings-hero > div {
position: relative;
z-index: 1;
}
.settings-hero h2 {
margin: 4px 0 6px;
font-size: clamp(26px, 4vw, 42px);
line-height: 0.95;
letter-spacing: -0.05em;
}
.settings-hero p {
max-width: 62ch;
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
.settings-kicker { .settings-kicker {
color: var(--accent-strong); color: var(--accent-strong);
font-size: 10px; font-size: 10px;
@@ -695,24 +640,6 @@ button:disabled {
text-transform: uppercase; text-transform: uppercase;
} }
.settings-hero-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
min-width: 220px;
}
.settings-hero-badges span {
padding: 6px 9px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.045);
color: var(--bg-ink);
font-size: 11px;
font-weight: 700;
}
.settings-layout { .settings-layout {
display: grid; display: grid;
grid-template-columns: 220px minmax(0, 1fr); grid-template-columns: 220px minmax(0, 1fr);
@@ -720,9 +647,14 @@ button:disabled {
align-items: start; align-items: start;
} }
.settings-nav { .settings-sidebar {
position: sticky; position: sticky;
top: 72px; top: 72px;
display: grid;
gap: 10px;
}
.settings-nav {
display: grid; display: grid;
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
@@ -891,39 +823,34 @@ button:disabled {
background: rgba(255, 255, 255, 0.018); background: rgba(255, 255, 255, 0.018);
} }
.settings-apply-bar { .settings-apply-card {
position: sticky; display: grid;
top: 72px;
z-index: 5;
display: flex;
gap: 12px; gap: 12px;
align-items: center; padding: 13px;
justify-content: space-between;
padding: 14px 16px;
border: 1px solid rgba(214, 130, 88, 0.32); border: 1px solid rgba(214, 130, 88, 0.32);
border-radius: 16px; border-radius: 16px;
background: background:
linear-gradient(135deg, rgba(214, 130, 88, 0.2), transparent 56%), linear-gradient(145deg, rgba(214, 130, 88, 0.16), transparent 62%),
rgba(16, 20, 18, 0.96); rgba(19, 24, 22, 0.88);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28); box-shadow: var(--card-shadow);
backdrop-filter: blur(16px);
} }
.settings-apply-bar > div { .settings-apply-card > div {
display: grid; display: grid;
gap: 3px; gap: 3px;
min-width: 0; min-width: 0;
} }
.settings-apply-bar strong { .settings-apply-card strong {
color: var(--bg-ink); color: var(--bg-ink);
font-size: 15px; font-size: 15px;
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.settings-apply-bar small { .settings-apply-card small {
color: var(--muted); color: var(--muted);
font-size: 11px; font-size: 11px;
line-height: 1.45;
} }
.settings-row-inline { .settings-row-inline {
@@ -1272,23 +1199,25 @@ button:disabled {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.settings-nav { .settings-sidebar {
position: static; position: static;
}
.settings-nav {
grid-template-columns: repeat(5, minmax(120px, 1fr)); grid-template-columns: repeat(5, minmax(120px, 1fr));
overflow-x: auto; overflow-x: auto;
} }
.settings-apply-card {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.settings-hero, .settings-apply-card {
.settings-apply-bar {
align-items: stretch; align-items: stretch;
flex-direction: column; grid-template-columns: 1fr;
}
.settings-hero-badges {
justify-content: flex-start;
min-width: 0;
} }
.settings-form-grid, .settings-form-grid,

View File

@@ -8,6 +8,7 @@ import { createServer, type ViteDevServer } from 'vite';
interface MockApiState { interface MockApiState {
codexFeatures: { goals: boolean }; codexFeatures: { goals: boolean };
codexFeatureUpdates: number; codexFeatureUpdates: number;
ciWatcherFailures: number;
restartRequests: number; restartRequests: number;
} }
@@ -110,6 +111,30 @@ async function main() {
}, },
); );
await runScenario(
'inbox stays focused on actionable work',
browser,
baseUrl,
async (page, state) => {
state.ciWatcherFailures = 2;
await page.goto(new URL('/#/inbox', baseUrl).toString(), {
waitUntil: 'networkidle',
});
await assertVisible(page.locator('#inbox .empty-state'));
assert.equal(await page.getByText(/CI 실패|CI failure/).count(), 0);
assert.equal(
await page.getByRole('button', { name: 'Dismiss' }).count(),
0,
);
// This scenario protects the Inbox information architecture. The
// accessibility scan stays scoped to Settings, where this UX suite
// currently has stable interactive coverage.
},
);
console.log('dashboard:ux passed'); console.log('dashboard:ux passed');
} finally { } finally {
await browser.close(); await browser.close();
@@ -156,6 +181,7 @@ function createMockApiState(): MockApiState {
return { return {
codexFeatures: { goals: false }, codexFeatures: { goals: false },
codexFeatureUpdates: 0, codexFeatureUpdates: 0,
ciWatcherFailures: 0,
restartRequests: 0, restartRequests: 0,
}; };
} }
@@ -165,9 +191,10 @@ async function openSettings(page: Page, baseUrl: string) {
waitUntil: 'networkidle', waitUntil: 'networkidle',
}); });
await assertVisible(page.locator('.settings-panel')); await assertVisible(page.locator('.settings-panel'));
await assertVisible(page.locator('.settings-hero')); assert.equal(await page.locator('.settings-hero').count(), 0);
await assertVisible(page.locator('.settings-sidebar'));
await assertVisible(page.locator('.settings-nav')); await assertVisible(page.locator('.settings-nav'));
await assertVisible(page.locator('.settings-apply-bar')); await assertVisible(page.locator('.settings-apply-card'));
assert.equal( assert.equal(
await page.getByRole('button', { name: '스택 재시작' }).count(), await page.getByRole('button', { name: '스택 재시작' }).count(),
1, 1,
@@ -209,9 +236,9 @@ async function handleMockApi(route: Route, state: MockApiState) {
tasks: { tasks: {
total: 0, total: 0,
active: 0, active: 0,
paused: 0, paused: state.ciWatcherFailures,
completed: 0, completed: 0,
watchers: { active: 0, paused: 0, completed: 0 }, watchers: { active: 0, paused: state.ciWatcherFailures, completed: 0 },
}, },
usage: { rows: [], fetchedAt: null }, usage: { rows: [], fetchedAt: null },
operations: { serviceRestarts: [] }, operations: { serviceRestarts: [] },

View File

@@ -761,7 +761,7 @@ describe('web dashboard room activity data', () => {
}); });
describe('web dashboard inbox data', () => { describe('web dashboard inbox data', () => {
it('builds typed inbox items from pending rooms, paired tasks, and CI failures', () => { it('builds typed inbox items from pending rooms and paired tasks only', () => {
const snapshots: StatusSnapshot[] = [ const snapshots: StatusSnapshot[] = [
{ {
serviceId: 'codex-main', serviceId: 'codex-main',
@@ -840,7 +840,6 @@ describe('web dashboard inbox data', () => {
}); });
expect(overview.inbox.map((item) => item.kind)).toEqual([ expect(overview.inbox.map((item) => item.kind)).toEqual([
'ci-failure',
'approval', 'approval',
'reviewer-request', 'reviewer-request',
'pending-room', 'pending-room',
@@ -866,18 +865,17 @@ describe('web dashboard inbox data', () => {
occurredAt: '2026-04-26T05:03:00.000Z', occurredAt: '2026-04-26T05:03:00.000Z',
}), }),
); );
const ciFailure = overview.inbox.find((item) => item.kind === 'ci-failure'); expect(overview.inbox.some((item) => item.kind === 'ci-failure')).toBe(
expect(ciFailure).toMatchObject({ false,
id: 'ci:ci-2', );
severity: 'error', expect(overview.inbox.some((item) => item.taskId === 'ci-1')).toBe(false);
occurrences: 2, expect(overview.inbox.some((item) => item.taskId === 'ci-2')).toBe(false);
source: 'scheduled-task',
taskId: 'ci-2',
lastOccurredAt: '2026-04-26T05:07:00.000Z',
});
expect(ciFailure?.summary).toContain('BOT_TOKEN=<redacted>');
expect(ciFailure?.summary).not.toContain('plain-secret-value');
expect(overview.inbox.some((item) => item.taskId === 'cron-1')).toBe(false); expect(overview.inbox.some((item) => item.taskId === 'cron-1')).toBe(false);
expect(overview.inbox.some((item) => item.taskId === 'done-1')).toBe(false); expect(overview.inbox.some((item) => item.taskId === 'done-1')).toBe(false);
expect(overview.tasks.watchers).toEqual({
active: 1,
paused: 1,
completed: 0,
});
}); });
}); });

View File

@@ -183,10 +183,6 @@ function buildPromptPreview(prompt: string): string {
return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim()); return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim());
} }
function buildInboxPreview(value: string): string {
return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim());
}
function buildRoomBody(value: string): string { function buildRoomBody(value: string): string {
return redactSensitiveText(value).trim(); return redactSensitiveText(value).trim();
} }
@@ -254,14 +250,6 @@ function normalizeStructuredVisibleContent(value: string): {
return splitLegacyImageTags(value); return splitLegacyImageTags(value);
} }
function stableHash(value: string): string {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) | 0;
}
return Math.abs(hash).toString(36);
}
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] { function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
const rows: UsageRowSnapshot[] = []; const rows: UsageRowSnapshot[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -282,21 +270,6 @@ function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
return rows; return rows;
} }
function failedTaskResult(task: ScheduledTask): string | null {
if (!task.last_result) return null;
const normalized = task.last_result.toLowerCase();
if (
normalized.includes('fail') ||
normalized.includes('error') ||
normalized.includes('timeout') ||
normalized.includes('cancel') ||
normalized.includes('reject')
) {
return buildInboxPreview(task.last_result);
}
return null;
}
function pairedTaskInboxKind( function pairedTaskInboxKind(
status: PairedTask['status'], status: PairedTask['status'],
): InboxItemKind | null { ): InboxItemKind | null {
@@ -383,31 +356,6 @@ function collectInboxItems(args: {
}); });
} }
for (const task of args.tasks) {
if (!isWatchCiTask(task)) continue;
const result = failedTaskResult(task);
if (!result) continue;
items.push({
id: `ci:${task.id}`,
groupKey: `ci:${stableHash(result.toLowerCase())}`,
kind: 'ci-failure',
severity: task.status === 'paused' ? 'error' : 'warn',
title: 'CI watcher failed',
summary: result,
occurredAt: task.last_run ?? task.created_at,
lastOccurredAt: task.last_run ?? task.created_at,
createdAt: args.createdAt,
occurrences: 1,
source: 'scheduled-task',
roomJid: task.chat_jid,
groupFolder: task.group_folder,
serviceId: task.agent_type ?? undefined,
taskId: task.id,
taskStatus: task.status,
});
}
const severityRank: Record<InboxItemSeverity, number> = { const severityRank: Record<InboxItemSeverity, number> = {
error: 0, error: 0,
warn: 1, warn: 1,