Merge pull request #80 from phj1081/codex/test-dashboard-route-state-helpers

Harden dashboard room helpers and labels
This commit is contained in:
Eyejoker
2026-04-28 20:36:06 +09:00
committed by GitHub
5 changed files with 142 additions and 9 deletions

View File

@@ -24,8 +24,13 @@ const formatters = {
formatDate: (value: string | null | undefined) => value ?? '-', formatDate: (value: string | null | undefined) => value ?? '-',
formatDuration: (value: number | null) => (value === null ? '-' : `${value}`), formatDuration: (value: number | null) => (value === null ? '-' : `${value}`),
formatLiveElapsed: (value: number) => `${value}`, formatLiveElapsed: (value: number) => `${value}`,
senderRoleClass: (value: string | null | undefined) => senderRoleClass: (value: string | null | undefined) => {
(value ?? '').toLowerCase().includes('owner') ? 'role-owner' : 'role-human', const role = (value ?? '').toLowerCase();
if (role.includes('owner')) return 'role-owner';
if (role.includes('reviewer')) return 'role-reviewer';
if (role.includes('arbiter')) return 'role-arbiter';
return 'role-human';
},
statusLabel: (status: string) => status, statusLabel: (status: string) => status,
}; };
@@ -110,6 +115,51 @@ describe('RoomCardV2', () => {
expect(html).toContain('prod 배포 완료'); expect(html).toContain('prod 배포 완료');
}); });
it('localizes protocol role and verdict labels in Korean rooms', () => {
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({
pairedTask: {
id: 'task-1',
title: 'Review pending',
status: 'in_review',
roundTripCount: 2,
updatedAt: '2026-04-28T02:03:00.000Z',
currentTurn: null,
outputs: [
{
id: 1,
turnNumber: 2,
role: 'reviewer',
verdict: 'continue',
createdAt: '2026-04-28T02:02:00.000Z',
outputText: '검증 완료. 다음 단계로 진행 가능합니다.',
},
],
},
}),
activityLoading: false,
busy: false,
draft: '',
entry,
expanded: true,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: true,
t,
...formatters,
}),
);
expect(html).toContain('>리뷰어</span>');
expect(html).toContain('>계속</span>');
expect(html).not.toContain('>reviewer</span>');
expect(html).not.toContain('>continue</span>');
});
it('renders live progress as markdown', () => { it('renders live progress as markdown', () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
createElement(RoomCardV2, { createElement(RoomCardV2, {
@@ -244,6 +294,7 @@ describe('RoomCardV2', () => {
); );
expect(html).toContain('class="room-watcher-fold"'); expect(html).toContain('class="room-watcher-fold"');
expect(html).toContain('>리뷰어</strong>');
expect(html).toContain(watcherTail); expect(html).toContain(watcherTail);
}); });
}); });

View File

@@ -5,6 +5,7 @@ import type { DashboardOverview, DashboardRoomActivity } from './api';
import type { Locale, Messages } from './i18n'; import type { Locale, Messages } from './i18n';
import { ParsedBody } from './ParsedBody'; import { ParsedBody } from './ParsedBody';
import { RoomAttachmentGallery } from './RoomAttachmentGallery'; import { RoomAttachmentGallery } from './RoomAttachmentGallery';
import { displayRole, displayVerdict } from './roomDisplayLabels';
import { import {
buildRoomThreadEntries, buildRoomThreadEntries,
isWatcherRoomMessage, isWatcherRoomMessage,
@@ -421,7 +422,9 @@ function RoomCurrentTurnSummary({
return ( return (
<div className="room-current-turn"> <div className="room-current-turn">
<span> <span>
<strong className={senderRoleClass(turn.role)}>{turn.role}</strong> <strong className={senderRoleClass(turn.role)}>
{displayRole(turn.role, locale)}
</strong>
<em>{turn.intentKind}</em> <em>{turn.intentKind}</em>
{turn.attemptNo > 1 ? ( {turn.attemptNo > 1 ? (
<small> <small>
@@ -504,7 +507,9 @@ function CollapsedLiveTurn({
<header> <header>
<span className="live-dot" aria-hidden /> <span className="live-dot" aria-hidden />
<span className="live-label">LIVE</span> <span className="live-label">LIVE</span>
<strong className={senderRoleClass(turn.role)}>{turn.role}</strong> <strong className={senderRoleClass(turn.role)}>
{displayRole(turn.role, locale)}
</strong>
<em>{turn.intentKind}</em> <em>{turn.intentKind}</em>
<span className="live-state pill pill-processing">{turn.state}</span> <span className="live-state pill pill-processing">{turn.state}</span>
{turn.executorServiceId ? ( {turn.executorServiceId ? (
@@ -574,7 +579,7 @@ function CollapsedWatcherStrip({
<span className="watcher-tag"> {watcherCount}</span> <span className="watcher-tag"> {watcherCount}</span>
<span className="watcher-line"> <span className="watcher-line">
<strong className={senderRoleClass(lastWatcher.senderName)}> <strong className={senderRoleClass(lastWatcher.senderName)}>
{lastWatcher.senderName} {displayRole(lastWatcher.senderName, locale)}
</strong> </strong>
<span>{lastWatcher.content.slice(0, 90)}</span> <span>{lastWatcher.content.slice(0, 90)}</span>
</span> </span>
@@ -764,7 +769,7 @@ function RoomTimelineEntry({
<li className={`room-timeline-item ${sectionClass}`}> <li className={`room-timeline-item ${sectionClass}`}>
<header className="room-timeline-header"> <header className="room-timeline-header">
<span className={`role-chip ${senderRoleClass(entry.senderName)}`}> <span className={`role-chip ${senderRoleClass(entry.senderName)}`}>
{entry.senderName} {displayRole(entry.senderName, locale)}
</span> </span>
<time>{formatDate(entry.timestamp, locale)}</time> <time>{formatDate(entry.timestamp, locale)}</time>
{entry.turnNumber !== undefined ? ( {entry.turnNumber !== undefined ? (
@@ -774,7 +779,7 @@ function RoomTimelineEntry({
) : null} ) : null}
{verdict ? ( {verdict ? (
<span className={`parsed-marker parsed-marker-${verdictTone}`}> <span className={`parsed-marker parsed-marker-${verdictTone}`}>
{verdict} {displayVerdict(verdict, locale)}
</span> </span>
) : null} ) : null}
</header> </header>
@@ -818,7 +823,7 @@ function RoomLiveTimelineEntry({
<span className="paused-dot" aria-hidden /> <span className="paused-dot" aria-hidden />
)} )}
<span className={`role-chip ${senderRoleClass(turn.role)}`}> <span className={`role-chip ${senderRoleClass(turn.role)}`}>
{turn.role} {displayRole(turn.role, locale)}
</span> </span>
<time> <time>
{formatDate(turn.progressUpdatedAt ?? turn.updatedAt, locale)} {formatDate(turn.progressUpdatedAt ?? turn.updatedAt, locale)}
@@ -866,7 +871,7 @@ function RoomWatcherFold({
<li className="room-watcher-item" key={message.id}> <li className="room-watcher-item" key={message.id}>
<header> <header>
<strong className={senderRoleClass(message.senderName)}> <strong className={senderRoleClass(message.senderName)}>
{message.senderName} {displayRole(message.senderName, locale)}
</strong> </strong>
<time>{formatDate(message.timestamp, locale)}</time> <time>{formatDate(message.timestamp, locale)}</time>
</header> </header>

View File

@@ -0,0 +1,34 @@
import type { Locale } from './i18n';
const KO_ROLE_LABELS: Record<string, string> = {
arbiter: '중재자',
owner: '오너',
reviewer: '리뷰어',
};
const KO_VERDICT_LABELS: Record<string, string> = {
blocked: '차단',
continue: '계속',
done: '완료',
done_with_concerns: '우려 있음',
needs_context: '컨텍스트 필요',
proceed: '진행',
reset: '초기화',
revise: '수정 필요',
step_done: '단계 완료',
task_done: '작업 완료',
};
export function displayRole(
value: string | null | undefined,
locale: Locale,
): string {
const raw = value ?? '';
if (locale !== 'ko') return raw;
return KO_ROLE_LABELS[raw.trim().toLowerCase()] ?? raw;
}
export function displayVerdict(value: string, locale: Locale): string {
if (locale !== 'ko') return value;
return KO_VERDICT_LABELS[value.trim().toLowerCase()] ?? value;
}

View File

@@ -77,6 +77,38 @@ function makeDeps(
} }
describe('web dashboard inbox action routes', () => { describe('web dashboard inbox action routes', () => {
it('tracks timestamped and global inbox dismissals separately', () => {
const tracker = createInboxDismissTracker();
tracker.dismiss('ci:task-1', '2026-04-26T05:00:00.000Z');
expect(
tracker.isDismissed({
id: 'ci:task-1',
lastOccurredAt: '2026-04-26T05:00:00.000Z',
}),
).toBe(true);
expect(
tracker.isDismissed({
id: 'ci:task-1',
lastOccurredAt: '2026-04-26T05:05:00.000Z',
}),
).toBe(false);
tracker.dismiss('ci:task-2', null);
expect(
tracker.isDismissed({
id: 'ci:task-2',
lastOccurredAt: '2026-04-26T05:00:00.000Z',
}),
).toBe(true);
expect(
tracker.isDismissed({
id: 'ci:task-2',
lastOccurredAt: '2026-04-26T05:05:00.000Z',
}),
).toBe(true);
});
it('dismisses inbox items and queues paired follow-up intents', async () => { it('dismisses inbox items and queues paired follow-up intents', async () => {
const dismissTracker = createInboxDismissTracker(); const dismissTracker = createInboxDismissTracker();
const task = makePairedTask({ id: 'merge-1', status: 'merge_ready' }); const task = makePairedTask({ id: 'merge-1', status: 'merge_ready' });

View File

@@ -68,6 +68,17 @@ function makeDeps(
} }
describe('web dashboard room message routes', () => { describe('web dashboard room message routes', () => {
it('expires the oldest remembered room message ids after the cache limit', () => {
const remember = createRoomMessageIdCache(2);
expect(remember('room:message-1')).toBe(true);
expect(remember('room:message-1')).toBe(false);
expect(remember('room:message-2')).toBe(true);
expect(remember('room:message-3')).toBe(true);
expect(remember('room:message-1')).toBe(true);
expect(remember('room:message-2')).toBe(true);
});
it('injects messages and deduplicates repeated request ids', async () => { it('injects messages and deduplicates repeated request ids', async () => {
const messages: NewMessage[] = []; const messages: NewMessage[] = [];
const queued: Array<{ chatJid: string; groupFolder: string }> = []; const queued: Array<{ chatJid: string; groupFolder: string }> = [];