sync: update from private repo (91d8d79c)
CI / build-and-test (push) Failing after 7m0s

This commit is contained in:
oss-sync
2026-07-09 23:57:26 +00:00
parent 63d34d7cf6
commit 2044f0a2c4
81 changed files with 3456 additions and 372 deletions
+3 -2
View File
@@ -47,6 +47,7 @@ interface LocalDetailPanelProps {
fileManagement?: FileManagement;
subtaskActivities?: SubtaskActivity[];
onSubtaskFilePreview?: SubtaskFilePreviewHandler;
onWorkspaceFilePreview?: (path: string) => void;
shareToken?: string | null;
onShareChange?: () => void;
/** When true, omit the DetailHeader (tab row + close button) and render only
@@ -64,7 +65,7 @@ export function LocalDetailPanel({
task, taskId, section, currentPath, entries, pathSegments,
loading, detailTab, detailWidth, showWidthToggle,
onTabChange, onWidthToggle, onClose, onDelete, onSectionChange, onNavigate, onPreview, onViewFullLog,
onRefresh, isRefreshing, fileManagement, subtaskActivities, onSubtaskFilePreview,
onRefresh, isRefreshing, fileManagement, subtaskActivities, onSubtaskFilePreview, onWorkspaceFilePreview,
shareToken, onShareChange, headerless = false, readonly = false,
}: LocalDetailPanelProps) {
const { t } = useTranslation('detail');
@@ -297,7 +298,7 @@ export function LocalDetailPanel({
{task?.latestJob?.status === 'waiting_human' && task?.latestJob?.waitReason === 'browser_login' && (
<BrowserSessionPanel />
)}
{deferredDetailTab === 'overview' && <OverviewTab task={task} subtaskActivities={subtaskActivities} onSubtaskFilePreview={onSubtaskFilePreview} readonly={readonly} />}
{deferredDetailTab === 'overview' && <OverviewTab task={task} subtaskActivities={subtaskActivities} onSubtaskFilePreview={onSubtaskFilePreview} onWorkspaceFilePreview={onWorkspaceFilePreview} readonly={readonly} />}
{deferredDetailTab === 'activity' && <ProgressTab task={task} onViewFullLog={onViewFullLog} subtaskActivities={subtaskActivities} />}
{deferredDetailTab === 'files' && <FilesTab section={section} currentPath={currentPath} entries={entries} pathSegments={pathSegments} taskId={taskId} onSectionChange={onSectionChange} onNavigate={onNavigate} onPreview={onPreview} onRefresh={onRefresh} isRefreshing={isRefreshing} management={fileManagement} />}
{deferredDetailTab === 'trace' && <TraceTab taskId={taskId} />}
@@ -8,40 +8,52 @@ import { initReactI18next } from 'react-i18next';
// Initialize i18next with minimal translations for this test.
// The component uses the 'detail' namespace.
const TEST_DETAIL_RESOURCES = {
'delegateRuns.eventsEmpty': 'No events',
'delegateRuns.subtaskGroupTitle': 'Subtask #{{n}}',
'delegateRuns.subtaskSectionHeading': 'Delegate runs in subtasks',
'delegateRuns.result': 'Result',
'delegateRuns.abortReason': 'Abort reason',
'delegateRuns.failedTool': 'Last failing tool: {{tool}}',
'delegateRuns.noDescription': '(no description)',
'delegateRuns.toolCount': '{{count}} tools',
// 実ロケール (ui/src/i18n/locales/en/detail.json) は toolCount に _one/_other の複数形
// バリアントを持つ。addResourceBundle の overwrite は完全一致するキーしか上書きしないため、
// 実ロケールが先に読み込まれた場合に備えてここでも明示的に単数形を無効化しておく。
'delegateRuns.toolCount_one': '{{count}} tools',
'delegateRuns.toolCount_other': '{{count}} tools',
'delegateRuns.childCount': '{{total}} children',
'delegateRuns.childCountFailed': '{{total}} children, {{failed}} failed',
'delegateRuns.moreEvents': '{{count}} more events (see the Trace tab for the full list)',
'delegateRuns.toolSummary': 'Tools used',
'delegateRuns.filesChanged': 'Changed files',
'delegateRuns.eventsToggle': 'Detailed events ({{count}})',
'subtasks.delegateSection': 'Delegated runs',
'subtasks.delegateStatus.success': 'Done',
'subtasks.delegateStatus.aborted': 'Aborted',
'subtasks.delegateStatus.running': 'Running',
'subtasks.delegateRunningTool': '{{tool}} running',
};
const TEST_COMMON_RESOURCES = { loading: 'Loading...' };
beforeAll(async () => {
if (!i18n.isInitialized) {
await i18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
resources: {
en: {
detail: {
'delegateRuns.eventsEmpty': 'No events',
'delegateRuns.subtaskGroupTitle': 'Subtask #{{n}}',
'delegateRuns.subtaskSectionHeading': 'Delegate runs in subtasks',
'delegateRuns.result': 'Result',
'delegateRuns.abortReason': 'Abort reason',
'delegateRuns.noDescription': '(no description)',
'delegateRuns.toolCount': '{{count}} tools',
'delegateRuns.childCount': '{{total}} children',
'delegateRuns.childCountFailed': '{{total}} children, {{failed}} failed',
'delegateRuns.moreEvents': '{{count}} more events (see the Trace tab for the full list)',
'delegateRuns.toolSummary': 'Tools used',
'delegateRuns.filesChanged': 'Changed files',
'delegateRuns.eventsToggle': 'Detailed events ({{count}})',
'subtasks.delegateSection': 'Delegated runs',
'subtasks.delegateStatus.success': 'Done',
'subtasks.delegateStatus.aborted': 'Aborted',
'subtasks.delegateStatus.running': 'Running',
'subtasks.delegateRunningTool': '{{tool}} running',
},
common: { loading: 'Loading...' },
},
// DelegateRunsSection.tsx は lib/utilsisPreviewable)経由で実 i18n シングルトン
// (../../../i18n) を静的 import しており、モジュール読み込み時の副作用で本番ロケール
// toolCount_one/_other など複数形リソースを含む)が先に初期化されてしまうことがある。
// isInitialized の状態に関わらず常に再 init し、このテストが前提とする簡易文言に
// 確実に揃える(i18next の init は複数回呼んでよい)。
await i18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
resources: {
en: {
detail: TEST_DETAIL_RESOURCES,
common: TEST_COMMON_RESOURCES,
},
defaultNS: 'common',
interpolation: { escapeValue: false },
});
}
},
defaultNS: 'common',
interpolation: { escapeValue: false },
});
});
vi.mock('../../../api', () => ({
@@ -113,6 +125,21 @@ vi.mock('../../../api', () => ({
toolCalls: 0,
resultPreview: 'Failed',
totalTokens: 100,
lastErrorTool: 'Bash',
},
{
delegateRunId: 'p5',
parentRunId: null,
description: 'ツール不明の失敗した委譲',
depth: 1,
status: 'aborted',
startTs: '2026-01-01T00:07:00Z',
endTs: '2026-01-01T00:07:30Z',
eventCount: 1,
toolCalls: 0,
resultPreview: 'Failed without tool',
totalTokens: 0,
lastErrorTool: null,
},
{
delegateRunId: 'p3',
@@ -448,6 +475,44 @@ describe('DelegateRunsSection', () => {
expect(screen.getByText('output/summary.txt')).toBeInTheDocument();
});
it('onWorkspaceFilePreview を渡すと、filesChanged のパスをクリックしてそのパス文字列で呼ばれる', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const onWorkspaceFilePreview = vi.fn();
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} onWorkspaceFilePreview={onWorkspaceFilePreview} />
</QueryClientProvider>,
);
// 親委譲のカードを開く(filesChanged が設定されている)
await screen.findByText('親委譲');
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
await userEvent.click(parentButton);
const fileButton = screen.getByText('output/result.md');
expect(fileButton.tagName).toBe('BUTTON');
await userEvent.click(fileButton);
expect(onWorkspaceFilePreview).toHaveBeenCalledWith('output/result.md');
});
it('onWorkspaceFilePreview 未提供なら filesChanged のパスは従来通り div のまま(クリックできない)', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 親委譲のカードを開く(filesChanged が設定されている)
await screen.findByText('親委譲');
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
await userEvent.click(parentButton);
const fileEl = screen.getByText('output/result.md');
expect(fileEl.tagName).not.toBe('BUTTON');
});
it('filesChanged 空または undefined なら見出しごと出ない', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
@@ -497,4 +562,81 @@ describe('DelegateRunsSection', () => {
// トグルが開いた状態になる
expect(eventsToggle).toHaveAttribute('aria-expanded', 'true');
});
it('説明文 span に title 属性が付く(狭い時に truncate されてもホバーで全文参照できる)', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
const descriptionSpan = await screen.findByText('親委譲');
expect(descriptionSpan).toHaveAttribute('title', '親委譲');
});
it('説明文 span に min-w-[8ch] クラスが含まれる(優先的に幅を確保する)', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
const descriptionSpan = await screen.findByText('親委譲');
expect(descriptionSpan).toHaveClass('min-w-[8ch]');
});
it('lastErrorTool ありの aborted run で「最後に失敗したツール」行が表示される', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 失敗した子委譲のカードを開く(親「失敗した親委譲」を開いてから子を開く)
await screen.findByText('失敗した親委譲');
const parentButton = screen.getByText('失敗した親委譲').closest('button') as HTMLButtonElement;
await userEvent.click(parentButton);
await screen.findByText('失敗した子委譲');
const childButton = screen.getByText('失敗した子委譲').closest('button') as HTMLButtonElement;
await userEvent.click(childButton);
expect(screen.getByText('Last failing tool: Bash')).toBeInTheDocument();
});
it('lastErrorTool が null の aborted run では「最後に失敗したツール」行が表示されない', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
await screen.findByText('ツール不明の失敗した委譲');
const button = screen.getByText('ツール不明の失敗した委譲').closest('button') as HTMLButtonElement;
await userEvent.click(button);
expect(screen.queryByText(/Last failing tool/)).not.toBeInTheDocument();
});
it('メタ情報 span は shrink-0 を含まない(狭い時に優先的に truncate される)', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
await screen.findByText('親委譲');
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
const metaSpan = Array.from(parentButton.querySelectorAll('span')).find((el) =>
el.textContent?.includes('tools') && el.textContent?.includes('·'),
);
expect(metaSpan).toBeDefined();
expect(metaSpan).not.toHaveClass('shrink-0');
expect(metaSpan).toHaveClass('truncate', 'min-w-0');
});
});
@@ -6,6 +6,7 @@ import { fetchDelegateRuns, fetchDelegateRunTimeline, type TraceEventLite } from
import { buildDelegateRunTree, currentRunningTool, delegateStatusBadge, formatElapsed, formatTokens, summarizeDescendants, type DelegateRunNode, type DelegateRun, type SubtaskDelegateGroup } from '../../../lib/delegateRuns';
import { useNow } from '../../../hooks/useNow';
import { summarizeTraceEvent } from '../../../lib/traceEvent';
import { isPreviewable } from '../../../lib/utils';
function EventLine({ event }: { event: TraceEventLite }) {
const summary = summarizeTraceEvent(event);
@@ -18,7 +19,7 @@ function EventLine({ event }: { event: TraceEventLite }) {
);
}
function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: DelegateRunNode; indent?: number; jobId?: string }) {
function RunCard({ taskId, node, indent = 0, jobId, onWorkspaceFilePreview }: { taskId: number; node: DelegateRunNode; indent?: number; jobId?: string; onWorkspaceFilePreview?: (path: string) => void }) {
const { t } = useTranslation('detail');
const [open, setOpen] = useState(false);
const [eventsOpen, setEventsOpen] = useState(false);
@@ -71,10 +72,13 @@ function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: De
{childBadgeContent}
</span>
)}
<span className="text-[13px] text-slate-800 font-medium truncate flex-1">
<span
className="text-[13px] text-slate-800 font-medium truncate flex-1 min-w-[8ch]"
title={node.description || t('delegateRuns.noDescription')}
>
{node.description || t('delegateRuns.noDescription')}
</span>
<span className="shrink-0 text-xs text-slate-400">
<span className="min-w-0 truncate text-xs text-slate-400">
{t('delegateRuns.toolCount', { count: node.toolCalls })} · {formatElapsed(node.startTs, node.endTs, now)}{node.totalTokens && node.totalTokens > 0 ? ` · ${formatTokens(node.totalTokens)}` : ''}
</span>
<span className="shrink-0 text-slate-400 text-xs ml-1" aria-hidden="true">{open ? '▲' : '▼'}</span>
@@ -96,6 +100,11 @@ function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: De
<div className="font-semibold mb-1">
{t(node.status === 'aborted' ? 'delegateRuns.abortReason' : 'delegateRuns.result')}
</div>
{node.status === 'aborted' && node.lastErrorTool && (
<div className="text-[11px] font-mono mb-1">
{t('delegateRuns.failedTool', { tool: node.lastErrorTool })}
</div>
)}
<div className="whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
{node.resultPreview}
</div>
@@ -117,11 +126,23 @@ function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: De
<div className="mt-2">
<div className="text-[10px] text-slate-500 mb-1">{t('delegateRuns.filesChanged')}</div>
<div>
{node.filesChanged.map((path, idx) => (
<div key={idx} className="font-mono text-[11px] text-slate-600 truncate" title={path}>
{path}
</div>
))}
{node.filesChanged.map((path, idx) => {
const previewable = isPreviewable(path);
return previewable && onWorkspaceFilePreview ? (
<button
key={idx}
onClick={() => onWorkspaceFilePreview(path)}
className="block w-full font-mono text-[11px] text-blue-600 hover:underline truncate text-left"
title={path}
>
{path}
</button>
) : (
<div key={idx} className="font-mono text-[11px] text-slate-600 truncate" title={path}>
{path}
</div>
);
})}
</div>
</div>
)}
@@ -151,7 +172,7 @@ function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: De
</>
)}
{node.children.map((c) => (
<RunCard key={c.delegateRunId} taskId={taskId} node={c} indent={indent + 1} jobId={jobId} />
<RunCard key={c.delegateRunId} taskId={taskId} node={c} indent={indent + 1} jobId={jobId} onWorkspaceFilePreview={onWorkspaceFilePreview} />
))}
</div>
)}
@@ -159,7 +180,7 @@ function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: De
);
}
export function DelegateRunsSection({ taskId }: { taskId: number }) {
export function DelegateRunsSection({ taskId, onWorkspaceFilePreview }: { taskId: number; onWorkspaceFilePreview?: (path: string) => void }) {
const { t } = useTranslation('detail');
const { data } = useQuery({
queryKey: ['delegate-runs', taskId],
@@ -182,7 +203,7 @@ export function DelegateRunsSection({ taskId }: { taskId: number }) {
<div className="mb-4">
<div className="text-sm font-bold text-slate-800 mb-2">{t('subtasks.delegateSection')}</div>
{tree.map((n) => (
<RunCard key={n.delegateRunId} taskId={taskId} node={n} />
<RunCard key={n.delegateRunId} taskId={taskId} node={n} onWorkspaceFilePreview={onWorkspaceFilePreview} />
))}
{subtasks.map((group) => {
const groupTree = buildDelegateRunTree(group.runs);
@@ -192,7 +213,7 @@ export function DelegateRunsSection({ taskId }: { taskId: number }) {
{t('delegateRuns.subtaskGroupTitle', { n: group.issueNumber })}
</div>
{groupTree.map((n) => (
<RunCard key={n.delegateRunId} taskId={taskId} node={n} jobId={group.jobId} />
<RunCard key={n.delegateRunId} taskId={taskId} node={n} jobId={group.jobId} onWorkspaceFilePreview={onWorkspaceFilePreview} />
))}
</div>
);
@@ -451,9 +451,10 @@ interface OverviewTabProps {
subtaskActivities?: SubtaskActivity[];
readonly?: boolean;
onSubtaskFilePreview?: SubtaskFilePreviewHandler;
onWorkspaceFilePreview?: (path: string) => void;
}
export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview, readonly = false }: OverviewTabProps) {
export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview, onWorkspaceFilePreview, readonly = false }: OverviewTabProps) {
const status = task.latestJob?.status ?? 'queued';
return (
@@ -494,7 +495,7 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview, rea
{/* delegate サブ実行: SpawnSubTask の有無に関係なく表示(runが無ければ自己非表示)。
SubtasksPanel は subtasks.length>0 でしかマウントされないため、delegate のみの
タスクでも見えるよう独立して描画する。 */}
<DelegateRunsSection taskId={task.id} />
<DelegateRunsSection taskId={task.id} onWorkspaceFilePreview={onWorkspaceFilePreview} />
</div>
);
}
+3 -37
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import type { PageId } from '../../lib/urlState';
import type { AuthUser } from '../../App';
import { ThemeToggle } from './ThemeToggle';
import { ChangePasswordDialog } from '../admin/LocalUserDialogs';
import { UserAccountMenu } from './UserAccountMenu';
interface TopBarProps {
currentPage: PageId;
@@ -92,7 +92,6 @@ export function TopBar({
}: TopBarProps) {
const { t } = useTranslation('layout');
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const [showPwChange, setShowPwChange] = useState(false);
// Collapse-to-hamburger decision from MEASURED widths, so it flips exactly
// when the tabs stop fitting — no width estimate, no 2-line state.
@@ -226,41 +225,8 @@ export function TopBar({
<span aria-hidden>K</span>
</button>
)}
<ThemeToggle />
{user && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name ?? user.email}
className="w-6 h-6 rounded-full object-cover"
/>
) : (
<div className="w-6 h-6 rounded-full bg-surface-2 text-slate-700 flex items-center justify-center text-2xs font-semibold uppercase">
{(user.name ?? user.email).charAt(0)}
</div>
)}
<span className="text-xs text-slate-600 hidden md:inline max-w-[120px] truncate">
{user.name ?? user.email}
</span>
</div>
<button
type="button"
onClick={() => setShowPwChange(true)}
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
>
{t('user.changePassword')}
</button>
<a
href="/auth/logout"
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
>
{t('user.logout')}
</a>
</div>
)}
{showPwChange && <ChangePasswordDialog onClose={() => setShowPwChange(false)} />}
{!user && <ThemeToggle />}
{user && <UserAccountMenu user={user} onNavigate={onNavigate} />}
</div>
</div>
</div>
@@ -0,0 +1,117 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { AuthUser } from '../../App';
// react-i18next: pass keys through (mirrors WorkerStatusWidget.test.tsx). The
// component only relies on translated *keys*, not the rendered copy, so this
// keeps the test independent of locale JSON wording.
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { UserAccountMenu } from './UserAccountMenu';
// ThemeToggle (rendered inside the open panel) reads matchMedia for
// prefers-color-scheme; jsdom doesn't implement it (see ChatPane.drafts.test.tsx
// for the same minimal stub).
beforeEach(() => {
vi.stubGlobal('matchMedia', (query: string) => ({
matches: false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
});
function mkUser(over: Partial<AuthUser> = {}): AuthUser {
return {
id: 'u1',
email: '[email protected]',
name: 'Alice',
avatarUrl: null,
role: 'user',
...over,
};
}
describe('UserAccountMenu', () => {
it('starts closed and opens the panel on trigger click', async () => {
const user = userEvent.setup();
render(<UserAccountMenu user={mkUser()} onNavigate={vi.fn()} />);
expect(screen.queryByTestId('user-account-menu-panel')).not.toBeInTheDocument();
const trigger = screen.getByTestId('user-account-menu-trigger');
expect(trigger).toHaveAttribute('aria-haspopup', 'true');
expect(trigger).toHaveAttribute('aria-expanded', 'false');
await user.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('user-account-menu-panel')).toBeInTheDocument();
});
it('shows user name/email and closes on outside click', async () => {
const user = userEvent.setup();
render(
<div>
<UserAccountMenu user={mkUser({ name: 'Alice', email: '[email protected]' })} onNavigate={vi.fn()} />
<button>outside</button>
</div>,
);
await user.click(screen.getByTestId('user-account-menu-trigger'));
const panel = screen.getByTestId('user-account-menu-panel');
expect(within(panel).getByText('Alice')).toBeInTheDocument();
expect(within(panel).getByText('[email protected]')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'outside' }));
expect(screen.queryByTestId('user-account-menu-panel')).not.toBeInTheDocument();
});
it('closes on Escape and returns focus to the trigger', async () => {
const user = userEvent.setup();
render(<UserAccountMenu user={mkUser()} onNavigate={vi.fn()} />);
const trigger = screen.getByTestId('user-account-menu-trigger');
await user.click(trigger);
expect(screen.getByTestId('user-account-menu-panel')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByTestId('user-account-menu-panel')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
it('renders a logout link pointing at /auth/logout', async () => {
const user = userEvent.setup();
render(<UserAccountMenu user={mkUser()} onNavigate={vi.fn()} />);
await user.click(screen.getByTestId('user-account-menu-trigger'));
const logoutLink = screen.getByRole('link', { name: 'user.logout' });
expect(logoutLink).toHaveAttribute('href', '/auth/logout');
});
it('calls onNavigate("settings") and closes when the settings item is clicked', async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(<UserAccountMenu user={mkUser()} onNavigate={onNavigate} />);
await user.click(screen.getByTestId('user-account-menu-trigger'));
await user.click(screen.getByRole('button', { name: 'nav.settings' }));
expect(onNavigate).toHaveBeenCalledWith('settings');
expect(screen.queryByTestId('user-account-menu-panel')).not.toBeInTheDocument();
});
it('renders the theme toggle inside the open panel', async () => {
const user = userEvent.setup();
render(<UserAccountMenu user={mkUser()} onNavigate={vi.fn()} />);
await user.click(screen.getByTestId('user-account-menu-trigger'));
const panel = screen.getByTestId('user-account-menu-panel');
// ThemeToggle renders a role="group" with 3 theme option buttons (system/light/dark).
expect(within(panel).getByRole('group')).toBeInTheDocument();
expect(within(panel).getAllByRole('button', { name: /theme\./ }).length).toBeGreaterThanOrEqual(3);
const systemBtn = within(panel).getByRole('button', { name: 'theme.system' });
await user.click(systemBtn);
expect(systemBtn).toHaveAttribute('aria-pressed', 'true');
});
});
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PageId } from '../../lib/urlState';
import type { AuthUser } from '../../App';
import { ThemeToggle } from './ThemeToggle';
interface UserAccountMenuProps {
user: AuthUser;
onNavigate: (page: PageId) => void;
}
/**
* TopBar 右上のアカウントメニュー。アバター/名前のトリガーをクリックするとパネルが開き、
* ユーザー名/メール(読み取り専用)、ダークモード切替、設定リンク、ログアウトを表示する。
* 開閉制御は FileToolbar.tsx の FileSortMenu と同じ mousedown+Escape クリックアウト実装を踏襲。
* role="menu" 等の完全な ARIA menu パターンは実装コストに見合わないため使わず(issue #799
* 設計メモ参照)、通常の button/link 群 + aria-haspopup/aria-expanded で足りる。
*/
export function UserAccountMenu({ user, onNavigate }: UserAccountMenuProps) {
const { t } = useTranslation('layout');
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const displayName = user.name ?? user.email;
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
const close = () => setOpen(false);
return (
<div ref={containerRef} className="relative flex-shrink-0">
<button
ref={triggerRef}
type="button"
data-testid="user-account-menu-trigger"
onClick={() => setOpen(v => !v)}
aria-haspopup="true"
aria-expanded={open}
aria-label={t('accountMenu.open', { name: displayName })}
className="flex items-center gap-1.5 px-1 py-1 rounded-md hover:bg-surface transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover" />
) : (
<div className="w-6 h-6 rounded-full bg-surface-2 text-slate-700 flex items-center justify-center text-2xs font-semibold uppercase">
{displayName.charAt(0)}
</div>
)}
<span className="text-xs text-slate-600 hidden md:inline max-w-[120px] truncate">
{displayName}
</span>
</button>
{open && (
<div
data-testid="user-account-menu-panel"
className="absolute right-0 top-[calc(100%+6px)] z-50 min-w-[220px] bg-canvas border border-hairline rounded-md shadow-lg p-2"
>
<div className="px-2 py-1.5 border-b border-hairline mb-1.5">
<p className="text-xs font-semibold text-slate-900 truncate">{displayName}</p>
{user.name && <p className="text-2xs text-slate-500 truncate">{user.email}</p>}
</div>
<div className="px-2 py-1.5 flex items-center justify-between gap-2">
<span className="text-2xs text-slate-500">{t('theme.label')}</span>
<ThemeToggle />
</div>
<button
type="button"
onClick={() => { close(); onNavigate('settings'); }}
className="w-full text-left px-2 py-1.5 rounded text-xs text-slate-700 hover:bg-surface-2 transition-colors"
>
{t('nav.settings')}
</button>
<a
href="/auth/logout"
onClick={close}
className="block w-full text-left px-2 py-1.5 rounded text-xs text-slate-700 hover:bg-surface-2 transition-colors"
>
{t('user.logout')}
</a>
</div>
)}
</div>
);
}
@@ -0,0 +1,300 @@
// @vitest-environment jsdom
/**
* Save-blocking through the REAL ConfigForm save bar (Task 6 reviewer fix):
* an invalid extra_body draft must disable Save & Apply even when a
* DIFFERENT field is dirty — otherwise the operator clicks Save, the
* invalid extra_body edit is silently dropped, and everything looks saved.
*
* Wiring under test: ExtraBodyField → onValidityChange (SectionFormProps)
* → ConfigForm's invalidKeys set → Save & Apply disabled + hint. Also the
* stale-key failure mode: removing the offending worker row must clear its
* invalid flag (unmount cleanup), or Save would stay bricked forever.
*
* ConfigForm's import graph initializes the real i18n, so (like
* AuthForm.test.tsx and friends) we pin the language to 'en' and assert
* against i18n.t(...) instead of raw keys.
*/
import '../../test/dom-setup';
import { beforeAll, beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
import { ConfigForm } from './ConfigForm';
// ConfigForm pulls useAuthState from App.tsx (huge module graph) and loads
// the config via react-query; stub both so we can drive the real save bar +
// section-form wiring without a server. `mockConfigData` is assigned
// per-test before render (the mock factory closes over it lazily).
let mockConfigData: any;
vi.mock('../../App', () => ({ useAuthState: () => ({ mode: 'disabled' }) }));
vi.mock('../../lib/unsavedGuard', () => ({ useUnsavedGuard: () => {} }));
vi.mock('../../hooks/useConfig', () => ({
useConfig: () => ({ data: mockConfigData, isLoading: false, error: null, refetch: vi.fn() }),
}));
beforeAll(async () => {
await i18n.changeLanguage('en');
});
beforeEach(() => {
// Never let ModelSelect hit the network.
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })) as any,
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const tSettings = (key: string) => i18n.t(key, { ns: 'settings' }) as string;
function renderConfigForm(config: any) {
mockConfigData = { config, etag: 'etag-1', overriddenByEnv: {} };
renderWithProviders(<ConfigForm section="llm-workers" isAdmin={true} />);
}
const saveButton = () =>
screen.getByRole('button', { name: /Save & Apply/ }) as HTMLButtonElement;
const discardButton = () =>
screen.getByRole('button', { name: /Discard/ }) as HTMLButtonElement;
async function dirtyAnotherField() {
// Rename the worker id so the draft is dirty independently of extra_body.
const id = screen.getByDisplayValue('w1');
await userEvent.clear(id);
await userEvent.type(id, 'w1-renamed');
}
describe('ConfigForm blocks Save & Apply while extra_body draft is invalid', () => {
it('disables Save & Apply and shows a hint while extra_body is invalid, even with another dirty field', async () => {
renderConfigForm({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
await dirtyAnotherField();
expect(saveButton()).toBeEnabled();
const textarea = screen.getByLabelText('extra_body');
await userEvent.click(textarea);
await userEvent.paste('{not valid json');
expect(saveButton()).toBeDisabled();
expect(screen.getByText(tSettings('configForm.invalidBlocked'))).toBeInTheDocument();
});
it('re-enables Save & Apply once the JSON is fixed', async () => {
renderConfigForm({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
await dirtyAnotherField();
const textarea = screen.getByLabelText('extra_body');
await userEvent.click(textarea);
await userEvent.paste('{not valid json');
expect(saveButton()).toBeDisabled();
await userEvent.clear(textarea);
await userEvent.click(textarea);
await userEvent.paste('{"reasoning_effort":"high"}');
expect(saveButton()).toBeEnabled();
expect(screen.queryByText(tSettings('configForm.invalidBlocked'))).toBeNull();
});
it('clears the invalid flag when the offending worker row is removed (no stale key bricking Save)', async () => {
renderConfigForm({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
await dirtyAnotherField();
const textarea = screen.getByLabelText('extra_body');
await userEvent.click(textarea);
await userEvent.paste('{not valid json');
expect(saveButton()).toBeDisabled();
await userEvent.click(screen.getByTitle(tSettings('llmWorkers.removeWorker')));
// Row unmounted → its invalid flag must be cleared; the draft is still
// dirty (worker array changed), so Save must be clickable again.
expect(saveButton()).toBeEnabled();
});
});
/**
* Multi-worker index-reuse regression (Task 6 P2 codex finding).
*
* With an array-index React `key` / validity fieldKey, deleting/reordering a
* row causes React to reuse the row-0 component instance for a DIFFERENT
* worker. `ExtraBodyField`'s local draft/error only re-syncs from the
* `value` prop when that prop differs from what the field itself last
* emitted — and an invalid draft is, by design, never emitted (see the
* ExtraBodyField doc comment), so `lastEmitted` stays at its pre-edit value.
* When the surviving worker's `extraBody` happens to equal that same
* pre-edit value (e.g. both undefined), the re-sync guard sees no change
* and the stale invalid text "ghosts" onto the wrong row — and/or the
* invalid-flag key never gets a matching unmount, bricking Save even after
* the offending worker is gone. Keying by a stable per-row uid (see
* `uidsRef` in LlmWorkersForm) fixes this because React then unmounts the
* removed row's own component instead of reusing it for its neighbour.
*/
describe('ConfigForm extra_body identity survives row delete/reorder (index-key regression)', () => {
function twoWorkers() {
return {
llm: {
workers: [
{ id: 'w1', endpoint: 'http://a/v1' },
{ id: 'w2', endpoint: 'http://b/v1' },
],
},
};
}
it('(a) deleting the invalid row 0 re-enables Save and leaves no ghost text on the surviving row', async () => {
renderConfigForm(twoWorkers());
const textareaBefore = screen.getAllByLabelText('extra_body');
expect(textareaBefore).toHaveLength(2);
await userEvent.click(textareaBefore[0]);
await userEvent.paste('{not valid json');
expect(saveButton()).toBeDisabled();
// Delete row 0 (the invalid one). Only row 1 (worker w2) should remain.
const removeButtons = screen.getAllByTitle(tSettings('llmWorkers.removeWorker'));
await userEvent.click(removeButtons[0]);
// The surviving row is worker w2, which never had extra_body touched —
// its textarea must show ITS OWN (empty) draft, not the invalid text
// that was typed into the now-deleted row 0.
const textareaAfter = screen.getAllByLabelText('extra_body');
expect(textareaAfter).toHaveLength(1);
expect(textareaAfter[0]).toHaveValue('');
expect(screen.queryByText(tSettings('llmWorkers.extraBodyInvalid'))).toBeNull();
// The offending row's invalid flag must be cleared on its unmount —
// Save is re-enabled (the worker-array delete itself is a dirty edit).
expect(saveButton()).toBeEnabled();
});
it('(b) deleting row 1 leaves row 0s invalid draft and blocked Save untouched', async () => {
renderConfigForm(twoWorkers());
const textareas = screen.getAllByLabelText('extra_body');
await userEvent.click(textareas[0]);
await userEvent.paste('{not valid json');
expect(saveButton()).toBeDisabled();
const removeButtons = screen.getAllByTitle(tSettings('llmWorkers.removeWorker'));
await userEvent.click(removeButtons[1]);
const textareaAfter = screen.getAllByLabelText('extra_body');
expect(textareaAfter).toHaveLength(1);
expect(textareaAfter[0]).toHaveValue('{not valid json');
expect(screen.getByText(tSettings('llmWorkers.extraBodyInvalid'))).toBeInTheDocument();
expect(saveButton()).toBeDisabled();
});
it('(c) moving the invalid row down carries its draft to the new position', async () => {
renderConfigForm(twoWorkers());
const textareas = screen.getAllByLabelText('extra_body');
await userEvent.click(textareas[0]);
await userEvent.paste('{not valid json');
expect(saveButton()).toBeDisabled();
const moveDownButtons = screen.getAllByTitle(tSettings('llmWorkers.moveDown'));
await userEvent.click(moveDownButtons[0]);
const textareasAfter = screen.getAllByLabelText('extra_body');
expect(textareasAfter).toHaveLength(2);
// The invalid draft followed worker w1 to row index 1; row 0 (now w2)
// shows its own empty draft.
expect(textareasAfter[0]).toHaveValue('');
expect(textareasAfter[1]).toHaveValue('{not valid json');
expect(screen.getByText(tSettings('llmWorkers.extraBodyInvalid'))).toBeInTheDocument();
expect(saveButton()).toBeDisabled();
});
});
/**
* Discard Changes / external-refetch stuck-invalid regression (Task 6 P2
* codex finding, reviewer follow-up to the fix above).
*
* `ExtraBodyField` only re-syncs its local draft+error from the `value`
* prop when that prop differs from what the field itself last emitted
* (`lastEmitted.current`). An invalid draft is by design never emitted, so
* `value` (and `lastEmitted.current`) both stay at their pre-edit value
* (often `undefined`) the whole time the operator is typing bad JSON. If
* the operator then discards (or the draft gets reset out from under them
* by an external refetch) WITHOUT the value prop changing, the field never
* notices — its red frame + error text persist, and `invalidKeys` in
* ConfigForm keeps the phantom key forever, since neither side has a
* reason to clear it. Save & Apply stays permanently blocked until a full
* page reload, even though the "invalid" draft has already been thrown
* away.
*/
describe('ConfigForm clears phantom invalid state on Discard / external refetch (Task 6 P2 fix)', () => {
it('BUG: Discard Changes with a dirty OTHER field must clear the phantom invalid extra_body state', async () => {
renderConfigForm({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
await dirtyAnotherField();
const textarea = screen.getByLabelText('extra_body');
await userEvent.click(textarea);
await userEvent.paste('{not valid json');
expect(screen.getByText(tSettings('llmWorkers.extraBodyInvalid'))).toBeInTheDocument();
expect(saveButton()).toBeDisabled();
await userEvent.click(discardButton());
// The reported bug: without the fix, the red error text and the
// invalidBlocked hint both survive discard, and Save stays disabled
// forever — even after the operator makes an unrelated, perfectly
// valid edit.
expect(screen.queryByText(tSettings('llmWorkers.extraBodyInvalid'))).toBeNull();
expect(screen.queryByText(tSettings('configForm.invalidBlocked'))).toBeNull();
const id = screen.getByDisplayValue('w1');
await userEvent.clear(id);
await userEvent.type(id, 'w1-again');
expect(saveButton()).toBeEnabled();
});
it('discard with a VALID in-progress extra_body edit still reverts to the original value (no regression)', async () => {
renderConfigForm({
llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1', extraBody: { reasoning_effort: 'low' } }] },
});
await dirtyAnotherField();
const textarea = screen.getByLabelText('extra_body');
await userEvent.clear(textarea);
await userEvent.click(textarea);
await userEvent.paste('{"reasoning_effort":"high"}');
expect(screen.getByLabelText('extra_body')).toHaveValue('{"reasoning_effort":"high"}');
await userEvent.click(discardButton());
expect(screen.getByLabelText('extra_body')).toHaveValue(
JSON.stringify({ reasoning_effort: 'low' }, null, 2),
);
// Fully reverted (the id rename was discarded too) → nothing left dirty.
expect(saveButton()).toBeDisabled();
expect(discardButton()).toBeDisabled();
});
it('an external data refetch (e.g. a save-conflict reload) also clears a phantom invalid extra_body key', async () => {
const original = { llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } };
mockConfigData = { config: original, etag: 'etag-1', overriddenByEnv: {} };
const { rerender } = renderWithProviders(<ConfigForm section="llm-workers" isAdmin={true} />);
const textarea = screen.getByLabelText('extra_body');
await userEvent.click(textarea);
await userEvent.paste('{not valid json');
expect(screen.getByText(tSettings('llmWorkers.extraBodyInvalid'))).toBeInTheDocument();
// Simulate the [data] sync effect's refetch path: a fresh `data` object
// (new reference, as react-query produces after `refetch()`) replaces
// the draft without the operator clicking anything.
mockConfigData = { config: original, etag: 'etag-2', overriddenByEnv: {} };
rerender(<ConfigForm section="llm-workers" isAdmin={true} />);
expect(screen.queryByText(tSettings('llmWorkers.extraBodyInvalid'))).toBeNull();
expect(screen.getByLabelText('extra_body')).toHaveValue('');
const id = screen.getByDisplayValue('w1');
await userEvent.clear(id);
await userEvent.type(id, 'w1-renamed');
expect(saveButton()).toBeEnabled();
});
});
+52 -3
View File
@@ -52,6 +52,7 @@ function PreferencesFormWrapper() {
user={{
defaultVisibility: auth.user.defaultVisibility ?? 'private',
defaultVisibilityOrgId: auth.user.defaultVisibilityOrgId ?? null,
hasLocalCredential: auth.user.hasLocalCredential,
}}
/>
);
@@ -161,14 +162,39 @@ function ConfigFormInner({ section }: ConfigFormProps) {
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const [toastIsError, setToastIsError] = useState(false);
// Field keys currently flagged invalid via SectionFormProps.onValidityChange.
// While non-empty, Save & Apply is disabled: an invalid draft never reaches
// the config draft (the field withholds onChange), so allowing a save from
// another dirty field would silently discard the invalid field's edit while
// looking saved. Reporting components clear their key on unmount (see the
// contract on SectionFormProps), so section switches / row removals cannot
// leave Save bricked by a stale key.
const [invalidKeys, setInvalidKeys] = useState<ReadonlySet<string>>(new Set());
// Bumped whenever the draft is replaced wholesale out from under any
// in-progress local field state (Discard Changes, or a fresh `data`
// load/refetch below). Section forms with local "in-progress draft"
// state that doesn't purely derive from the `value` prop (see
// ExtraBodyField in LlmWorkersForm) fold this into their row keys to
// force a remount — see SectionFormProps.resetToken for the contract.
const [resetToken, setResetToken] = useState(0);
// Sync fetched config into draft
// Sync fetched config into draft. This effect only re-runs when the
// `data` object identity changes — i.e. the initial load and any
// subsequent refetch (e.g. after a save-conflict reload) — never on
// every render, so bumping resetToken here only fires on a genuine
// external replacement of the draft, matching the Discard Changes case
// below. Also clears invalidKeys: any field-local invalid draft that
// hasn't reached this fresh `data` (and therefore never reached the
// server) is being discarded along with the rest of the draft, so a
// phantom invalid key must not survive to permanently block Save.
useEffect(() => {
if (data) {
setDraft(data.config);
setEtag(data.etag);
setOverriddenByEnv(data.overriddenByEnv);
setIsDirty(false);
setInvalidKeys(new Set());
setResetToken(t => t + 1);
}
}, [data]);
@@ -177,10 +203,27 @@ function ConfigFormInner({ section }: ConfigFormProps) {
setIsDirty(true);
}, []);
const handleValidityChange = useCallback((fieldKey: string, valid: boolean) => {
setInvalidKeys(prev => {
if (valid ? !prev.has(fieldKey) : prev.has(fieldKey)) return prev; // no-op → keep identity
const next = new Set(prev);
if (valid) next.delete(fieldKey);
else next.add(fieldKey);
return next;
});
}, []);
const handleDiscard = () => {
if (data) {
setDraft(data.config);
setIsDirty(false);
// Discard doesn't change `data`, so the [data] sync effect above
// won't fire — clear any phantom invalid key and bump resetToken
// here directly so field-local draft state (e.g. ExtraBodyField's
// in-progress textarea + JSON error) remounts from the reverted
// value instead of getting stuck showing a stale error forever.
setInvalidKeys(new Set());
setResetToken(t => t + 1);
}
};
@@ -218,7 +261,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
if (error) return <div className="text-sm text-red-500">{t('configForm.loadError')}</div>;
if (!draft) return null;
const formProps = { config: draft, onChange: handleChange, overriddenByEnv };
const formProps = { config: draft, onChange: handleChange, overriddenByEnv, onValidityChange: handleValidityChange, resetToken };
const sectionForm = (() => {
switch (section) {
@@ -290,6 +333,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
})();
const dirty = dirtyCount > 0;
const blockedByInvalid = invalidKeys.size > 0;
return (
<div className="max-w-2xl pb-20">
@@ -309,6 +353,11 @@ function ConfigFormInner({ section }: ConfigFormProps) {
<span className={`text-2xs mr-auto ${toastIsError ? 'text-red-600' : 'text-emerald-700 dark:text-emerald-300'}`}>
{toast}
</span>
) : blockedByInvalid ? (
<span className="text-xs mr-auto text-red-600 dark:text-red-400 flex items-center gap-1.5 font-medium min-w-0">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" aria-hidden />
<span className="truncate">{t('configForm.invalidBlocked')}</span>
</span>
) : dirty ? (
<span className="text-xs mr-auto text-amber-800 dark:text-amber-300 flex items-center gap-1.5 font-medium min-w-0">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse flex-shrink-0" aria-hidden />
@@ -328,7 +377,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
</button>
<button
onClick={handleSave}
disabled={!dirty || saving}
disabled={!dirty || saving || blockedByInvalid}
className="px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
>
{saving ? 'Saving...' : (
@@ -0,0 +1,120 @@
// @vitest-environment jsdom
/**
* Component tests for the extra_body / reasoning_efforts / reasoning_effort_mode
* fields added to LlmWorkersForm (Phase 0, Task 6).
*
* Focus:
* - invalid JSON in the extra_body textarea shows an inline error and never
* reaches onChange (so the invalid draft can never be saved — see the
* ExtraBodyField comment in LlmWorkersForm.tsx for the rationale)
* - valid JSON round-trips into the worker's `extraBody` field
* - an empty textarea serializes to `undefined` (field absent), not `{}`
* - non-object JSON (array/string/number) is rejected the same as malformed JSON
* - reasoning_efforts: comma-separated text -> trimmed non-empty string[],
* empty input -> undefined
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { LlmWorkersForm } from './LlmWorkersForm';
function render(config: any) {
const onChange = vi.fn();
renderWithProviders(<LlmWorkersForm config={config} onChange={onChange} overriddenByEnv={{}} />);
return { onChange };
}
function renderStateful(config: any) {
const onChange = vi.fn();
const utils = renderStatefulForm(LlmWorkersForm, config, { onChangeSpy: onChange });
return { onChange, ...utils };
}
beforeEach(() => {
// Never let ModelSelect hit the network.
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })) as any,
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('LlmWorkersForm extra_body / reasoning_efforts / reasoning_effort_mode', () => {
it('shows an inline error for invalid JSON and never calls onChange with it', async () => {
const { onChange } = render({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
const textarea = screen.getByLabelText('extra_body');
await userEvent.click(textarea);
await userEvent.paste('{not valid json');
expect(screen.getByText('llmWorkers.extraBodyInvalid')).toBeInTheDocument();
// No call ever carried a `workers` patch containing an extraBody derived
// from this invalid text (undefined is fine — that's the initial state).
const extraBodyValues = onChange.mock.calls
.filter(([path]) => path === 'llm.workers')
.map(([, value]) => value[0]?.extraBody);
for (const v of extraBodyValues) {
expect(v).toBeUndefined();
}
});
it('rejects non-object JSON (array) the same as malformed JSON', async () => {
render({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
const textarea = screen.getByLabelText('extra_body');
await userEvent.click(textarea);
await userEvent.paste('[1,2,3]');
expect(screen.getByText('llmWorkers.extraBodyInvalid')).toBeInTheDocument();
});
it('round-trips valid JSON into worker.extraBody', async () => {
const { onChange, getConfig } = renderStateful({
llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] },
});
const textarea = screen.getByLabelText('extra_body');
// Fire as a single change (paste-like) so we exercise the full parse once,
// avoiding transient-invalid states while React re-renders mid-keystroke.
await userEvent.click(textarea);
await userEvent.paste('{"reasoning_effort":"high"}');
expect(screen.queryByText('llmWorkers.extraBodyInvalid')).toBeNull();
expect(getConfig().llm.workers[0].extraBody).toEqual({ reasoning_effort: 'high' });
const lastWorkersCall = onChange.mock.calls.filter(([p]) => p === 'llm.workers').at(-1)!;
expect(lastWorkersCall[1][0].extraBody).toEqual({ reasoning_effort: 'high' });
});
it('serializes an emptied extra_body textarea to undefined, not {}', async () => {
const { getConfig } = renderStateful({
llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1', extraBody: { a: 1 } }] },
});
const textarea = screen.getByLabelText('extra_body');
await userEvent.clear(textarea);
expect(getConfig().llm.workers[0].extraBody).toBeUndefined();
});
it('serializes reasoning_efforts from comma-separated text to a trimmed string array', async () => {
const { getConfig } = renderStateful({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
const input = screen.getByLabelText('reasoning_efforts');
await userEvent.click(input);
await userEvent.paste('high, medium ,low');
expect(getConfig().llm.workers[0].reasoningEfforts).toEqual(['high', 'medium', 'low']);
});
it('serializes an emptied reasoning_efforts input to undefined', async () => {
const { getConfig } = renderStateful({
llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1', reasoningEfforts: ['high'] }] },
});
const input = screen.getByLabelText('reasoning_efforts');
await userEvent.clear(input);
expect(getConfig().llm.workers[0].reasoningEfforts).toBeUndefined();
});
it('serializes reasoning_effort_mode select to undefined/body/chat_template_kwargs', async () => {
const { getConfig } = renderStateful({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
const select = screen.getByLabelText('reasoning_effort_mode');
await userEvent.selectOptions(select, 'chat_template_kwargs');
expect(getConfig().llm.workers[0].reasoningEffortMode).toBe('chat_template_kwargs');
await userEvent.selectOptions(select, 'body');
expect(getConfig().llm.workers[0].reasoningEffortMode).toBe('body');
});
});
@@ -83,8 +83,10 @@ describe('LlmWorkersForm', () => {
const { onChange } = render({
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://a/v1' }] },
});
// The connection-type <select> is the only combobox in the row.
const select = screen.getByRole('combobox');
// The connection-type <select> has no accessible name of its own (label
// text comes from a sibling FieldLabel); reasoning_effort_mode does carry
// an aria-label, so filter it out to find the connection-type combobox.
const select = screen.getAllByRole('combobox').find(el => !el.hasAttribute('aria-label'))!;
await userEvent.selectOptions(select, 'aao_gateway');
let [path, value] = onChange.mock.calls.at(-1)!;
expect(path).toBe('llm.workers');
+205 -3
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
@@ -27,6 +27,22 @@ interface LlmWorker {
/** llama.cpp の prompt 評価進捗(return_progress)を要求。llama.cpp 系専用のオプトイン。 */
returnProgress?: boolean;
healthcheckIntervalSeconds?: number;
/**
* OpenAI 互換 request body へ浅いマージする任意 JSON(例:
* `{"reasoning_effort":"max"}`)。予約キー(model/messages/stream/
* stream_options/tools/tool_choice/temperature)はクライアント側で
* 無視される。空欄はフィールド自体を省略(`{}` ではなく undefined)。
*/
extraBody?: Record<string, unknown>;
/** Phase 1 で消費される、このワーカーが対応する reasoning effort の宣言リスト。 */
reasoningEfforts?: string[];
/**
* effort をリクエストボディへ注入する形。body=トップレベル
* reasoning_effortvLLM 向け)、chat_template_kwargs=
* chat_template_kwargs.reasoning_effortllama-server 向け)。
* 未指定は body 扱い。
*/
reasoningEffortMode?: 'body' | 'chat_template_kwargs';
/**
* Phase 1 compat: older `provider.workers[].proxy: true` rows are
* mapped to `connectionType: aao_gateway` by the normalizer. We
@@ -80,6 +96,100 @@ function detectSelfLoop(endpoint: string | undefined): boolean {
return false;
}
/**
* JSON textarea for `worker.extraBody`. The raw text the operator is
* mid-typing lives only in this component's state (same local-draft idea as
* `SecretInput`), and `onChange` (which writes into the config draft that
* `Save & Apply` persists) is called ONLY when the text parses as a JSON
* object. Invalid JSON — including valid-but-non-object JSON like an array,
* string, or number — therefore never reaches the draft.
*
* Because the invalid draft is withheld from the config draft, a save
* triggered by ANOTHER dirty field would silently drop it — so the field
* also reports its validity upward via `onValidityChange(fieldKey, valid)`
* (SectionFormProps contract): ConfigForm disables Save & Apply while any
* key is invalid. The flag is cleared on unmount (row removed, section
* switched) so a stale key can never permanently brick Save.
*
* If the `value` prop changes to something we did not emit (Discard Changes,
* row moved/removed above this one), the local draft re-syncs from the prop
* and any error is cleared.
*
* An emptied textarea reports `undefined` (field absent) rather than `{}`.
*/
function ExtraBodyField({ value, onChange, fieldKey, onValidityChange }: {
value: Record<string, unknown> | undefined;
onChange: (v: Record<string, unknown> | undefined) => void;
/** Stable identity for the validity flag, e.g. `llm.workers.0.extraBody`. */
fieldKey: string;
onValidityChange?: (fieldKey: string, valid: boolean) => void;
}) {
const { t } = useTranslation('settings');
const [text, setText] = useState(() => (value === undefined ? '' : JSON.stringify(value, null, 2)));
const [error, setError] = useState<string | null>(null);
// Last value WE pushed via onChange. If the prop diverges from it, the
// change came from outside (discard / row shift) → re-sync the draft.
const lastEmitted = useRef(value);
useEffect(() => {
if (value !== lastEmitted.current) {
lastEmitted.current = value;
setText(value === undefined ? '' : JSON.stringify(value, null, 2));
setError(null);
}
}, [value]);
// Report validity upward; clear the flag on unmount or key change so a
// removed row / switched section never leaves Save permanently disabled.
useEffect(() => {
onValidityChange?.(fieldKey, error === null);
return () => onValidityChange?.(fieldKey, true);
}, [fieldKey, error, onValidityChange]);
const handleChange = (raw: string) => {
setText(raw);
if (raw.trim() === '') {
setError(null);
lastEmitted.current = undefined;
onChange(undefined);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
setError(t('llmWorkers.extraBodyInvalid'));
return;
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
setError(t('llmWorkers.extraBodyInvalid'));
return;
}
setError(null);
const obj = parsed as Record<string, unknown>;
lastEmitted.current = obj;
onChange(obj);
};
return (
<div className="col-span-2">
<FieldLabel>extra_body</FieldLabel>
<textarea
aria-label="extra_body"
value={text}
onChange={e => handleChange(e.target.value)}
rows={4}
placeholder={'{"reasoning_effort": "high"}'}
className={`w-full px-2.5 py-2 text-[13px] font-mono border rounded-md focus:ring-2 focus:ring-accent-ring outline-none bg-canvas ${
error ? 'border-red-400 focus:border-red-400' : 'border-hairline focus:border-accent'
}`}
/>
{error && <p className="text-2xs text-red-600 mt-1">{error}</p>}
<HelpText>{t('llmWorkers.extraBodyHelp')}</HelpText>
</div>
);
}
/**
* Settings → LLM → Workers.
*
@@ -103,18 +213,48 @@ function detectSelfLoop(endpoint: string | undefined): boolean {
* - `aao_gateway` rows show a heuristic self-loop warning when the
* endpoint host looks like the current AAO instance
*/
export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
export function LlmWorkersForm({ config, onChange, overriddenByEnv, onValidityChange, resetToken }: SectionFormProps) {
const { t } = useTranslation('settings');
const llm: LlmConfigShape = config.llm ?? {};
const workers: LlmWorker[] = Array.isArray(llm.workers) ? llm.workers : [];
const retry = llm.retry ?? {};
// Stable per-row identity, independent of array position. `w.id` is a
// user-editable free-text field (can be empty/duplicated) so it isn't
// usable as a React key; array index breaks the moment a row above the
// one you're mid-editing is removed/reordered, because React then
// reuses that DOM/component instance for a *different* worker — the
// in-progress ExtraBodyField draft/validity (keyed by index) silently
// migrates to the wrong row. `uidsRef` mirrors the `workers` array
// 1:1 by position; addWorker/removeWorker/moveWorker mutate it in the
// same lockstep as their `onChange('llm.workers', ...)` call so a row's
// uid follows it across reorders and survives removal of *other* rows.
//
// The top-of-render check below only handles the array drifting out
// from under us for reasons other than the three handlers below (e.g.
// Discard Changes reverting a structural edit made in another tab, or
// the initial mount): if the lengths disagree, we don't know which
// positions correspond to which old rows, so we just regenerate fresh
// uids for the current shape. Any in-progress ExtraBodyField draft on
// screen at that moment loses its identity and unmounts/remounts —
// acceptable, since unsaved invalid JSON was never going to reach
// config.yaml anyway, and the unmount fires ConfigForm's validity
// cleanup so Save can't stay wedged.
const uidCounterRef = useRef(0);
const uidsRef = useRef<string[]>([]);
const nextUid = () => `w-${uidCounterRef.current++}`;
if (uidsRef.current.length !== workers.length) {
uidsRef.current = workers.map(() => nextUid());
}
const uids = uidsRef.current;
const updateWorker = (index: number, patch: Partial<LlmWorker>) => {
const next = workers.map((w, i) => (i === index ? { ...w, ...patch } : w));
onChange('llm.workers', next);
};
const removeWorker = (index: number) => {
uidsRef.current = uidsRef.current.filter((_, i) => i !== index);
onChange('llm.workers', workers.filter((_, i) => i !== index));
};
@@ -124,6 +264,12 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
const next = [...workers];
const [removed] = next.splice(index, 1);
next.splice(target, 0, removed);
const nextUids = [...uidsRef.current];
const [removedUid] = nextUids.splice(index, 1);
nextUids.splice(target, 0, removedUid);
uidsRef.current = nextUids;
onChange('llm.workers', next);
};
@@ -136,6 +282,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
maxConcurrency: 1,
roles: [],
};
uidsRef.current = [...uidsRef.current, nextUid()];
onChange('llm.workers', [...workers, next]);
};
@@ -165,12 +312,22 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
)}
{workers.map((w, i) => {
const uid = uids[i];
const isGateway = w.connectionType === 'aao_gateway' || w.proxy === true;
const showSelfLoop = isGateway && selfLoopFlags[i];
const endpointOverridden = i === 0 && overriddenByEnv['llm.workers[0].endpoint'];
const modelOverridden = i === 0 && overriddenByEnv['llm.workers[0].model'];
return (
<div key={i} className="border border-slate-200 rounded-lg p-4 space-y-3 relative">
// resetToken is folded into the key so Discard Changes / a
// fresh config load-refetch force a full remount of every row.
// That resyncs ExtraBodyField's local textarea+error draft from
// the reverted `value` prop even when the prop is unchanged
// from what the field itself last emitted (the case its own
// value-diff re-sync guard can't detect on its own) — see
// SectionFormProps.resetToken. ConfigForm clears invalidKeys
// directly in the same state update, so this remount only
// needs to reconcile the visuals, not the validity Set.
<div key={`${uid}:${resetToken ?? 0}`} className="border border-slate-200 rounded-lg p-4 space-y-3 relative">
<div className="absolute top-2 right-2 flex gap-1">
<button
onClick={() => moveWorker(i, -1)}
@@ -334,6 +491,51 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
{t('llmWorkers.returnProgress')}
</label>
</div>
<div className="col-span-2 border-t border-slate-100 pt-3 mt-1">
<p className="text-2xs font-medium text-slate-500 mb-2">{t('llmWorkers.advancedTitle')}</p>
</div>
<div className="col-span-2">
<FieldLabel>reasoning_efforts</FieldLabel>
<FieldInput
aria-label="reasoning_efforts"
value={Array.isArray(w.reasoningEfforts) ? w.reasoningEfforts.join(', ') : ''}
onChange={v => {
const efforts = v.split(',').map(s => s.trim()).filter(s => s.length > 0);
updateWorker(i, { reasoningEfforts: efforts.length > 0 ? efforts : undefined });
}}
placeholder="high, medium, low"
/>
<HelpText>{t('llmWorkers.reasoningEffortsHelp')}</HelpText>
</div>
<div>
<FieldLabel>reasoning_effort_mode</FieldLabel>
<select
aria-label="reasoning_effort_mode"
value={w.reasoningEffortMode ?? ''}
onChange={e => {
const next = e.target.value;
updateWorker(i, {
reasoningEffortMode: next === '' ? undefined : (next as 'body' | 'chat_template_kwargs'),
});
}}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
>
<option value="">{t('llmWorkers.reasoningEffortModeDefault')}</option>
<option value="body">body</option>
<option value="chat_template_kwargs">chat_template_kwargs</option>
</select>
<HelpText>{t('llmWorkers.reasoningEffortModeHelp')}</HelpText>
</div>
<ExtraBodyField
value={w.extraBody}
onChange={extraBody => updateWorker(i, { extraBody })}
fieldKey={`llm.workers.${uid}.extraBody`}
onValidityChange={onValidityChange}
/>
</div>
</div>
);
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/render-helpers';
// react-i18next: pass keys through, matching the convention used by other
// component tests in this repo (see WorkerStatusWidget.test.tsx). PreferencesForm
// imports SUPPORTED_LANGUAGES etc. from '../../i18n', which eagerly calls
// i18n.use(initReactI18next).init(...) — stub that hook too so the module load
// doesn't throw when react-i18next is mocked wholesale.
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key, i18n: { resolvedLanguage: 'en', changeLanguage: vi.fn() } }),
initReactI18next: { type: '3rdParty', init: () => {} },
}));
vi.mock('../../api', () => ({
fetchMyOrgs: vi.fn(async () => []),
}));
import { PreferencesForm } from './PreferencesForm';
describe('PreferencesForm — password change section (issue #799)', () => {
it('shows the change-password button for a local-credential account', () => {
renderWithProviders(
<PreferencesForm
user={{ defaultVisibility: 'private', defaultVisibilityOrgId: null, hasLocalCredential: true }}
/>,
);
const btn = screen.getByRole('button', { name: 'preferences.password.changeButton' });
expect(btn).toBeEnabled();
expect(screen.queryByText('preferences.password.oauthNotice')).not.toBeInTheDocument();
});
it('opens the ChangePasswordDialog when the button is clicked (local account)', async () => {
const { default: userEvent } = await import('@testing-library/user-event');
const user = userEvent.setup();
renderWithProviders(
<PreferencesForm
user={{ defaultVisibility: 'private', defaultVisibilityOrgId: null, hasLocalCredential: true }}
/>,
);
await user.click(screen.getByRole('button', { name: 'preferences.password.changeButton' }));
expect(screen.getByText('change.title')).toBeInTheDocument();
});
it('shows a disabled button + OAuth notice for an account with no local credential', () => {
renderWithProviders(
<PreferencesForm
user={{ defaultVisibility: 'private', defaultVisibilityOrgId: null, hasLocalCredential: false }}
/>,
);
const btn = screen.getByRole('button', { name: 'preferences.password.changeButton' });
expect(btn).toBeDisabled();
expect(screen.getByText('preferences.password.oauthNotice')).toBeInTheDocument();
});
it('treats an undefined hasLocalCredential the same as false (fail-closed)', () => {
renderWithProviders(
<PreferencesForm user={{ defaultVisibility: 'private', defaultVisibilityOrgId: null }} />,
);
expect(screen.getByRole('button', { name: 'preferences.password.changeButton' })).toBeDisabled();
expect(screen.getByText('preferences.password.oauthNotice')).toBeInTheDocument();
});
});
+35 -1
View File
@@ -4,11 +4,17 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY, type SupportedLanguage } from '../../i18n';
import { ChangePasswordDialog } from '../admin/LocalUserDialogs';
const LANGUAGE_LABELS: Record<SupportedLanguage, string> = { en: 'English', ja: '日本語' };
export function PreferencesForm({ user }: { user: { defaultVisibility: Visibility; defaultVisibilityOrgId: string | null } }) {
export function PreferencesForm({
user,
}: {
user: { defaultVisibility: Visibility; defaultVisibilityOrgId: string | null; hasLocalCredential?: boolean };
}) {
const { t, i18n } = useTranslation('settings');
const [showPwChange, setShowPwChange] = useState(false);
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs });
const qc = useQueryClient();
const [vis, setVis] = useState<Visibility>(user.defaultVisibility);
@@ -67,6 +73,33 @@ export function PreferencesForm({ user }: { user: { defaultVisibility: Visibilit
</ul>
<p className="mt-2 text-2xs text-slate-500">{t('preferences.orgs.refreshHint')}</p>
</section>
<section>
<h3 className="text-sm font-bold text-slate-900">{t('preferences.password.title')}</h3>
{user.hasLocalCredential ? (
<>
<HelpText>{t('preferences.password.help')}</HelpText>
<button
type="button"
onClick={() => setShowPwChange(true)}
className="mt-2 px-3 py-1.5 rounded-md text-[13px] font-medium border border-hairline text-slate-700 hover:bg-surface transition-colors"
>
{t('preferences.password.changeButton')}
</button>
</>
) : (
<>
<p className="mt-2 text-[13px] text-slate-500">{t('preferences.password.oauthNotice')}</p>
<button
type="button"
disabled
aria-disabled="true"
className="mt-2 px-3 py-1.5 rounded-md text-[13px] font-medium border border-hairline text-slate-400 bg-surface-2 cursor-not-allowed"
>
{t('preferences.password.changeButton')}
</button>
</>
)}
</section>
<button
onClick={() => save.mutate()}
disabled={save.isPending}
@@ -75,6 +108,7 @@ export function PreferencesForm({ user }: { user: { defaultVisibility: Visibilit
{save.isPending ? t('preferences.saving') : t('preferences.save')}
</button>
{save.isError && <div className="text-red-600 text-xs">{String(save.error)}</div>}
{showPwChange && <ChangePasswordDialog onClose={() => setShowPwChange(false)} />}
</div>
);
}
+4 -1
View File
@@ -22,9 +22,11 @@ interface FieldInputProps {
disabled?: boolean;
/** disabled の理由を tooltip として表示 */
disabledReason?: string;
/** アクセシブルネーム(可視ラベルが FieldLabel で別要素の場合の testing-library / a11y 用フック) */
'aria-label'?: string;
}
export function FieldInput({ value, onChange, type = 'text', placeholder, disabled, disabledReason }: FieldInputProps) {
export function FieldInput({ value, onChange, type = 'text', placeholder, disabled, disabledReason, ...rest }: FieldInputProps) {
return (
<input
type={type}
@@ -33,6 +35,7 @@ export function FieldInput({ value, onChange, type = 'text', placeholder, disabl
placeholder={placeholder}
disabled={disabled}
title={disabled ? disabledReason : undefined}
aria-label={rest['aria-label']}
className={`w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow ${
disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : 'bg-canvas'
}`}
+27
View File
@@ -2,4 +2,31 @@ export interface SectionFormProps {
config: any;
onChange: (path: string, value: any) => void;
overriddenByEnv: Record<string, boolean>;
/**
* Optional validity channel: a form calls this to mark a field draft as
* invalid (`valid: false`) or valid again (`valid: true`). ConfigForm
* disables Save & Apply while any field key is flagged invalid, so a
* half-typed draft that never reaches `onChange` (e.g. malformed JSON in
* the LLM worker `extra_body` textarea) cannot be silently dropped by a
* save triggered from another dirty field.
*
* Contract: `fieldKey` must be stable for the field instance, and the
* reporting component MUST clear its flag (`valid: true`) on unmount —
* a stale key would permanently disable Save. Optional so existing forms
* need no changes.
*/
onValidityChange?: (fieldKey: string, valid: boolean) => void;
/**
* Bumped by ConfigForm whenever the draft is reset out from under any
* in-progress local field state — Discard Changes, or a fresh `data`
* load/refetch (e.g. after a save-conflict reload). A section form with
* fields that hold their own local "in-progress draft" state (like
* `ExtraBodyField`'s raw textarea + JSON parse error, which only
* re-syncs from its `value` prop when that prop differs from what the
* field itself last emitted) can fold this into its React `key` so a
* reset forces a full remount instead of relying on the field to notice
* an unchanged-but-reverted prop. Optional — most forms have no local
* draft state and can ignore it.
*/
resetToken?: number;
}
@@ -99,9 +99,9 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
{week.map(d => {
const inMonth = d.slice(0, 7) === month;
const bySpace = counts[d];
// ドットは「タスクのある」スペースだけ。予定は下の横棒で表す
const taskSpaceIds = bySpace
? Object.keys(bySpace).filter(sid => bySpace[sid]!.taskCount > 0)
// ドットは「タスク or 予定」のある活動全般を示す(日詳細パネルの activeSpaces 判定と同じ基準)
const activeSpaceIds = bySpace
? Object.keys(bySpace).filter(sid => bySpace[sid]!.taskCount > 0 || bySpace[sid]!.eventCount > 0)
: [];
const isToday = d === today;
const isSelected = d === selectedDate;
@@ -126,22 +126,29 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
>
{Number(d.slice(8, 10))}
</span>
{taskSpaceIds.length > 0 && (
{activeSpaceIds.length > 0 && (
<span className="flex flex-wrap gap-0.5">
{taskSpaceIds.slice(0, 6).map(sid => {
{activeSpaceIds.slice(0, 6).map(sid => {
const sp = spaceById.get(sid);
const c = bySpace?.[sid];
const detail = [
c && c.taskCount > 0 ? t('calendar.taskCount', { count: c.taskCount }) : null,
c && c.eventCount > 0 ? t('calendar.eventCount', { count: c.eventCount }) : null,
]
.filter((p): p is string => p !== null)
.join(t('crossCalendar.detailSeparator'));
return (
<span
key={sid}
data-testid={`cross-cal-dot-${sid}`}
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: sp?.color ?? 'var(--brand-primary)' }}
title={t('crossCalendar.dotTitle', { name: sp?.name ?? sid, count: bySpace?.[sid]?.taskCount ?? 0 })}
title={t('crossCalendar.dotTitle', { name: sp?.name ?? sid, detail })}
/>
);
})}
{taskSpaceIds.length > 6 && (
<span className="text-[8px] font-bold text-slate-400">+{taskSpaceIds.length - 6}</span>
{activeSpaceIds.length > 6 && (
<span className="text-[8px] font-bold text-slate-400">+{activeSpaceIds.length - 6}</span>
)}
</span>
)}
+12 -3
View File
@@ -183,9 +183,18 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
>
{Number(d.slice(8, 10))}
</span>
{filters.tasks && c?.taskCount ? (
<span className="self-start rounded bg-sky-100 px-1 text-[9px] font-bold text-sky-700 dark:bg-sky-500/20 dark:text-sky-300" title={t('calendar.taskCount', { count: c.taskCount })}>
💬{c.taskCount}
{(filters.tasks && c?.taskCount) || (filters.events && c?.eventCount) ? (
<span className="flex flex-wrap items-center gap-1 self-start">
{filters.tasks && c?.taskCount ? (
<span className="rounded bg-sky-100 px-1 text-[9px] font-bold text-sky-700 dark:bg-sky-500/20 dark:text-sky-300" title={t('calendar.taskCount', { count: c.taskCount })}>
💬{c.taskCount}
</span>
) : null}
{filters.events && c?.eventCount ? (
<span className="rounded bg-amber-100 px-1 text-[9px] font-bold text-amber-700 dark:bg-amber-500/20 dark:text-amber-300" title={t('calendar.eventCount', { count: c.eventCount })}>
📌{c.eventCount}
</span>
) : null}
</span>
) : null}
</button>
+7
View File
@@ -710,6 +710,12 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
};
const handleSubtaskFilePreview = (tid: number, jobId: string, category: string, filePath: string) =>
previewSubtaskFile(tid, jobId, category, filePath);
// delegate カードの「変更ファイル」一覧クリック。filesChanged はワークスペース相対パス
// (例: output/report.md) で、section='workspace' はタスクルート直下を指すため prefix strip 不要。
const handleWorkspaceFilePreview = (filePath: string) => {
const name = filePath.includes('/') ? filePath.slice(filePath.lastIndexOf('/') + 1) : filePath;
previewLocalFile(taskId, 'workspace', filePath, name);
};
// 1タブ分のコンテンツ。デスクトップ(クリック)とモバイル(スワイプ)の両方が
// 同じ実体を使うため共通化(ロジック重複を避ける)。`chat` は ChatPane、それ以外は
@@ -762,6 +768,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
fileManagement={fileManagement}
subtaskActivities={subtaskActivities}
onSubtaskFilePreview={handleSubtaskFilePreview}
onWorkspaceFilePreview={handleWorkspaceFilePreview}
shareToken={task?.shareToken ?? null}
/>
);
+62 -2
View File
@@ -8,7 +8,7 @@
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { Space } from '../../api';
import i18n from '../../i18n';
@@ -16,8 +16,12 @@ import i18n from '../../i18n';
const useSpacesMock = vi.fn();
const useTaskListMock = vi.fn();
const useAuthStateMock = vi.fn();
const updateDisplayMock = { mutateAsync: vi.fn() };
vi.mock('../../hooks/useSpaces', () => ({ useSpaces: () => useSpacesMock() }));
vi.mock('../../hooks/useSpaces', () => ({
useSpaces: () => useSpacesMock(),
useUpdateSpaceDisplayPrefs: () => updateDisplayMock,
}));
vi.mock('../../hooks/useTaskList', () => ({ useLocalTaskList: () => useTaskListMock() }));
vi.mock('../../App', () => ({ useAuthState: () => useAuthStateMock() }));
vi.mock('./SpaceFormDialog', () => ({
@@ -52,6 +56,8 @@ beforeEach(() => {
useSpacesMock.mockReset();
useTaskListMock.mockReset();
useAuthStateMock.mockReset();
updateDisplayMock.mutateAsync.mockReset();
updateDisplayMock.mutateAsync.mockResolvedValue({ favorite: false, hidden: false });
useTaskListMock.mockReturnValue({ data: [] });
useAuthStateMock.mockReturnValue({ mode: 'disabled' });
});
@@ -96,6 +102,43 @@ describe('SpaceRail', () => {
expect(screen.getByText('Project A')).toBeInTheDocument();
});
it('shows favorites in a dedicated section and hides hidden workspaces from the normal list', () => {
useSpacesMock.mockReturnValue({
data: [
space({ id: 'fav', kind: 'case', title: 'Favorite Project', favorite: true }),
space({ id: 'normal', kind: 'case', title: 'Normal Project' }),
space({ id: 'hidden', kind: 'case', title: 'Hidden Project', hidden: true }),
],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
const groups = screen.getAllByTestId('space-group');
expect(groups.map((g) => g.getAttribute('data-group'))).toContain('お気に入り');
expect(screen.getByText('Favorite Project')).toBeInTheDocument();
expect(screen.getByText('Normal Project')).toBeInTheDocument();
expect(screen.queryByText('Hidden Project')).toBeNull();
fireEvent.click(screen.getByTestId('space-hidden-toggle'));
expect(screen.getByText('Hidden Project')).toBeInTheDocument();
expect(screen.getByTestId('space-hidden-badge')).toHaveTextContent('非表示');
});
it('search includes hidden workspaces with a hidden badge', () => {
useSpacesMock.mockReturnValue({
data: [
space({ id: 'visible', kind: 'case', title: 'Visible Project' }),
space({ id: 'hidden', kind: 'case', title: 'Hidden Project', hidden: true }),
],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
fireEvent.change(screen.getByTestId('space-search-input'), { target: { value: 'hidden' } });
expect(screen.getByText('Hidden Project')).toBeInTheDocument();
expect(screen.getByTestId('space-hidden-badge')).toHaveTextContent('非表示');
});
it('calls onSelect with the space id when a row is clicked', () => {
const onSelect = vi.fn();
useSpacesMock.mockReturnValue({
@@ -165,4 +208,21 @@ describe('SpaceRail', () => {
expect(onSelect).toHaveBeenCalledWith('new-space');
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
});
it('updates display settings from the row menu and can undo the action', async () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
fireEvent.click(screen.getByTestId('space-row-menu'));
fireEvent.click(screen.getByText('非表示にする'));
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { hidden: true } }));
expect(screen.getByTestId('space-display-toast')).toHaveTextContent('「Project A」を非表示にしました');
fireEvent.click(screen.getByTestId('space-display-undo'));
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { favorite: false, hidden: false } }));
});
});
+263 -59
View File
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState } from '../../App';
import { useSpaces } from '../../hooks/useSpaces';
import { useSpaces, useUpdateSpaceDisplayPrefs } from '../../hooks/useSpaces';
import { useLocalTaskList } from '../../hooks/useTaskList';
import { sortSpacesForRail } from '../../lib/spaceSort';
import { countRunningTasksForSpace } from '../../lib/spaceTasks';
@@ -14,60 +14,113 @@ interface SpaceRailProps {
onSelect: (id: string) => void;
}
// 可視性ラベルは private を出さない(既定値でノイズになるため)。org/public のみ
// 意味があるので表示する。
const VIS_LABEL: Partial<Record<Space['visibility'], string>> = {
org: 'org',
public: 'public',
};
// グループの色帯。統合スペース(個人=作業が集まる中心)はブランド色、個別スペース
// (案件=プロジェクトごとに分かれる)は中立色で、左端の帯で一目で区別する。
const GROUP_BAND_INTEGRATED = 'var(--brand-primary)';
const GROUP_BAND_INDIVIDUAL = '#94a3b8'; // slate-400
const GROUP_BAND_INDIVIDUAL = '#94a3b8';
const GROUP_BAND_FAVORITE = '#eab308';
interface SpaceGroupDef {
/** Stable key used for data-group (test selector); decoupled from the display label. */
key: string;
label: string;
band: string;
spaces: Space[];
}
interface UndoState {
spaceId: string;
title: string;
prev: { favorite: boolean; hidden: boolean };
message: string;
}
const normalize = (value: string) => value.trim().toLocaleLowerCase();
const isFavorite = (space: Space) => space.favorite === true;
const isHidden = (space: Space) => space.hidden === true;
export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
const { t } = useTranslation('spaces');
const { data: spaces, isLoading, isError } = useSpaces();
// 実行中件数の算出元。リスト API はスペースで絞らないので全件を保持しており、
// FAST ポーリングで自動更新される。スペースごとにクライアント側で数える。
const updateDisplay = useUpdateSpaceDisplayPrefs();
const { data: tasks } = useLocalTaskList();
const [showCreate, setShowCreate] = useState(false);
const [query, setQuery] = useState('');
const [showHidden, setShowHidden] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [undo, setUndo] = useState<UndoState | null>(null);
const [error, setError] = useState<string | null>(null);
const auth = useAuthState();
const myUserId = auth.mode === 'authenticated' ? auth.user.id : null;
// 認証無効 (no-auth 単独利用) は admin 同等に扱う(App.tsx の isAdmin と同じ規約)。
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
// case スペースの id 集合。個人スペースバッジの「個人バケツ」判定に使う。
const caseSpaceIds = useMemo(
() => new Set((spaces ?? []).filter(s => s.kind === 'case').map(s => s.id)),
[spaces],
);
const sorted = useMemo(() => sortSpacesForRail(spaces ?? []), [spaces]);
// 他ユーザー所有のスペースが一覧に混在するとき(=admin が全ユーザーのスペースを
// 見ている場合)だけ「自分」バッジを出す。単一ユーザーの一覧では全部自分なので
// ノイズにしかならず、出さない(issue #003)。
const caseSpaceIds = useMemo(
() => new Set(sorted.filter(s => s.kind === 'case').map(s => s.id)),
[sorted],
);
const hasOthersSpaces = useMemo(
() => myUserId != null && sorted.some(s => s.ownerId != null && s.ownerId !== myUserId),
[sorted, myUserId],
);
const q = normalize(query);
const searching = q.length > 0;
const matchesQuery = (space: Space) => normalize(space.title).includes(q);
const visibleSpaces = sorted.filter(s => !isHidden(s));
const hiddenSpaces = sorted.filter(isHidden);
const searchResults = searching ? sorted.filter(matchesQuery) : [];
const favoriteSpaces = !searching ? visibleSpaces.filter(isFavorite) : [];
const regularSpaces = !searching ? visibleSpaces.filter(s => !isFavorite(s)) : [];
const groups = useMemo<SpaceGroupDef[]>(() => {
const personal = sorted.filter(s => s.kind === 'personal');
const cases = sorted.filter(s => s.kind !== 'personal');
if (searching) {
return searchResults.length > 0
? [{ key: '検索結果', label: t('rail.searchResults'), band: GROUP_BAND_INDIVIDUAL, spaces: searchResults }]
: [];
}
const out: SpaceGroupDef[] = [];
if (favoriteSpaces.length > 0) {
out.push({ key: 'お気に入り', label: t('rail.group.favorites'), band: GROUP_BAND_FAVORITE, spaces: favoriteSpaces });
}
const personal = regularSpaces.filter(s => s.kind === 'personal');
const cases = regularSpaces.filter(s => s.kind !== 'personal');
if (personal.length > 0) out.push({ key: '統合スペース', label: t('rail.group.integrated'), band: GROUP_BAND_INTEGRATED, spaces: personal });
if (cases.length > 0) out.push({ key: '個別スペース', label: t('rail.group.individual'), band: GROUP_BAND_INDIVIDUAL, spaces: cases });
return out;
}, [sorted, t]);
}, [favoriteSpaces, regularSpaces, searchResults, searching, t]);
const runningCount = (space: Space) =>
countRunningTasksForSpace(tasks ?? [], space, { viewerId: myUserId, isAdmin, caseSpaceIds });
const changeDisplay = async (space: Space, patch: { favorite?: boolean; hidden?: boolean }, message: string) => {
setOpenMenuId(null);
setError(null);
const prev = { favorite: isFavorite(space), hidden: isHidden(space) };
try {
await updateDisplay.mutateAsync({ id: space.id, patch });
setUndo({ spaceId: space.id, title: space.title, prev, message });
} catch (e) {
setError(e instanceof Error ? e.message : t('rail.displayUpdateFailed'));
}
};
const undoChange = async () => {
if (!undo) return;
setError(null);
try {
await updateDisplay.mutateAsync({ id: undo.spaceId, patch: undo.prev });
setUndo(null);
} catch (e) {
setError(e instanceof Error ? e.message : t('rail.displayUpdateFailed'));
}
};
return (
<div className="flex h-full flex-col overflow-hidden" data-testid="space-rail">
@@ -83,6 +136,39 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
</button>
</div>
<div className="border-b border-hairline px-2 py-2">
<label className="relative block">
<span className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" aria-hidden>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7" cy="7" r="4" />
<path d="M10 10l3 3" />
</svg>
</span>
<input
data-testid="space-search-input"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('rail.searchPlaceholder')}
className="h-8 w-full rounded-md border border-hairline bg-surface pl-7 pr-7 text-xs text-slate-800 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-300 focus:bg-white"
/>
{query && (
<button
type="button"
data-testid="space-search-clear"
onClick={() => setQuery('')}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-1 text-slate-400 hover:bg-surface-2 hover:text-slate-700"
aria-label={t('rail.clearSearch')}
title={t('rail.clearSearch')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4l8 8" />
<path d="M12 4l-8 8" />
</svg>
</button>
)}
</label>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{isLoading && <p className="px-1 py-2 text-xs text-slate-500">{t('common:loading')}</p>}
{isError && <p className="px-1 py-2 text-xs text-red-600">{t('rail.fetchError')}</p>}
@@ -101,19 +187,78 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
key={s.id}
space={s}
active={s.id === selectedId}
menuOpen={openMenuId === s.id}
onToggleMenu={() => setOpenMenuId(openMenuId === s.id ? null : s.id)}
onSelect={onSelect}
runningCount={countRunningTasksForSpace(tasks ?? [], s, { viewerId: myUserId, isAdmin, caseSpaceIds })}
runningCount={runningCount(s)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
onFavorite={() => changeDisplay(s, { favorite: !isFavorite(s) }, isFavorite(s) ? t('rail.toast.favoriteRemoved', { title: s.title }) : t('rail.toast.favoriteAdded', { title: s.title }))}
onHide={() => changeDisplay(s, { hidden: true }, t('rail.toast.hidden', { title: s.title }))}
onRestore={() => changeDisplay(s, { hidden: false }, t('rail.toast.restored', { title: s.title }))}
/>
))}
</section>
))}
{!isLoading && !isError && sorted.length === 0 && (
{!isLoading && !isError && !searching && sorted.length === 0 && (
<p className="px-1 py-2 text-xs text-slate-500">{t('rail.empty')}</p>
)}
{!isLoading && !isError && searching && searchResults.length === 0 && (
<p className="px-1 py-2 text-xs text-slate-500">{t('rail.noSearchResults')}</p>
)}
{!searching && hiddenSpaces.length > 0 && (
<section data-testid="space-hidden-section" className="mt-2 border-t border-hairline pt-2">
<button
type="button"
data-testid="space-hidden-toggle"
onClick={() => setShowHidden(v => !v)}
className="mb-1 flex w-full items-center justify-between rounded px-1 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:bg-surface-2 hover:text-slate-600"
>
<span>{t('rail.hiddenToggle', { count: hiddenSpaces.length })}</span>
<svg className={`h-3.5 w-3.5 transition-transform ${showHidden ? 'rotate-180' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6l4 4 4-4" />
</svg>
</button>
{showHidden && hiddenSpaces.map(s => (
<SpaceRow
key={s.id}
space={s}
active={s.id === selectedId}
menuOpen={openMenuId === s.id}
onToggleMenu={() => setOpenMenuId(openMenuId === s.id ? null : s.id)}
onSelect={onSelect}
runningCount={runningCount(s)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
onFavorite={() => changeDisplay(s, { favorite: !isFavorite(s) }, isFavorite(s) ? t('rail.toast.favoriteRemoved', { title: s.title }) : t('rail.toast.favoriteAdded', { title: s.title }))}
onHide={() => changeDisplay(s, { hidden: true }, t('rail.toast.hidden', { title: s.title }))}
onRestore={() => changeDisplay(s, { hidden: false }, t('rail.toast.restored', { title: s.title }))}
/>
))}
</section>
)}
</div>
{(undo || error) && (
<div data-testid="space-display-toast" className="border-t border-hairline bg-slate-900 px-3 py-2 text-xs text-white">
{error ? (
<span>{error}</span>
) : undo ? (
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate">{undo.message}</span>
<button
type="button"
data-testid="space-display-undo"
onClick={undoChange}
className="shrink-0 rounded border border-white/30 px-2 py-1 font-semibold hover:bg-white/10"
>
{t('rail.undo')}
</button>
</div>
) : null}
</div>
)}
{showCreate && (
<SpaceFormDialog
onClose={() => setShowCreate(false)}
@@ -130,65 +275,124 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
function SpaceRow({
space,
active,
menuOpen,
onToggleMenu,
onSelect,
runningCount,
mine,
onFavorite,
onHide,
onRestore,
}: {
space: Space;
active: boolean;
menuOpen: boolean;
onToggleMenu: () => void;
onSelect: (id: string) => void;
runningCount: number;
/** 他ユーザーのスペースが混在する一覧で、これが閲覧者自身の所有なら true。 */
mine?: boolean;
onFavorite: () => void;
onHide: () => void;
onRestore: () => void;
}) {
const { t } = useTranslation('spaces');
const dot = space.brandColor ?? 'var(--brand-primary)';
const runningStyle = statusTone('running');
const hidden = isHidden(space);
const favorite = isFavorite(space);
return (
<button
type="button"
<div
data-testid="space-row"
data-space-kind={space.kind}
data-space-id={space.id}
data-space-hidden={hidden ? '1' : undefined}
data-space-favorite={favorite ? '1' : undefined}
data-space-mine={mine ? '1' : undefined}
onClick={() => onSelect(space.id)}
className={`mb-0.5 flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left transition-colors ${
className={`group relative mb-0.5 flex w-full items-center rounded-md border transition-colors ${
active
? 'border-hairline bg-[var(--brand-primary-soft)]'
: 'border-transparent hover:bg-surface-2'
: hidden
? 'border-transparent opacity-75 hover:bg-surface-2'
: 'border-transparent hover:bg-surface-2'
}`}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: dot }}
aria-hidden
/>
<span className="flex-1 truncate text-[12.5px] font-semibold text-slate-800">{space.title}</span>
{mine && (
<button
type="button"
onClick={() => onSelect(space.id)}
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
>
<span
data-testid="space-mine-badge"
className="shrink-0 rounded-full bg-[var(--brand-primary-soft)] px-1.5 py-0.5 text-[9px] font-bold tracking-wide text-[var(--brand-primary)]"
title={t('rail.mineTitle')}
>
{t('rail.mine')}
</span>
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: dot }}
aria-hidden
/>
{favorite && !hidden && (
<span className="shrink-0 text-[11px] text-amber-500" title={t('rail.favorite')} aria-label={t('rail.favorite')}></span>
)}
<span className="flex-1 truncate text-[12.5px] font-semibold text-slate-800">{space.title}</span>
{hidden && (
<span data-testid="space-hidden-badge" className="shrink-0 rounded-full bg-slate-100 px-1.5 py-0.5 text-[9px] font-bold tracking-wide text-slate-500">
{t('rail.hiddenBadge')}
</span>
)}
{mine && (
<span
data-testid="space-mine-badge"
className="shrink-0 rounded-full bg-[var(--brand-primary-soft)] px-1.5 py-0.5 text-[9px] font-bold tracking-wide text-[var(--brand-primary)]"
title={t('rail.mineTitle')}
>
{t('rail.mine')}
</span>
)}
{VIS_LABEL[space.visibility] && (
<span className="shrink-0 font-mono text-[9px] font-bold uppercase tracking-wide text-slate-400">
{VIS_LABEL[space.visibility]}
</span>
)}
{runningCount > 0 && (
<span
data-testid="space-running-count"
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold tabular-nums"
style={{ background: runningStyle.bg, color: runningStyle.fg }}
title={t('rail.runningTitle', { count: runningCount })}
aria-label={t('rail.runningAria', { count: runningCount })}
>
{t('rail.running', { count: runningCount })}
</span>
)}
</button>
<button
type="button"
data-testid="space-row-menu"
onClick={(e) => { e.stopPropagation(); onToggleMenu(); }}
className="mr-1 shrink-0 rounded p-1 text-slate-400 opacity-100 hover:bg-white/70 hover:text-slate-700 md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100"
aria-label={t('rail.menuLabel', { title: space.title })}
title={t('rail.menu')}
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
<circle cx="4" cy="8" r="1.2" />
<circle cx="8" cy="8" r="1.2" />
<circle cx="12" cy="8" r="1.2" />
</svg>
</button>
{menuOpen && (
<div data-testid="space-row-menu-panel" className="absolute right-1 top-8 z-20 w-40 rounded-md border border-hairline bg-white py-1 text-xs shadow-lg">
{!hidden && (
<button type="button" onClick={onFavorite} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
{favorite ? t('rail.menuUnfavorite') : t('rail.menuFavorite')}
</button>
)}
{hidden ? (
<button type="button" onClick={onRestore} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
{t('rail.menuRestore')}
</button>
) : (
<button type="button" onClick={onHide} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
{t('rail.menuHide')}
</button>
)}
</div>
)}
{VIS_LABEL[space.visibility] && (
<span className="shrink-0 font-mono text-[9px] font-bold uppercase tracking-wide text-slate-400">
{VIS_LABEL[space.visibility]}
</span>
)}
{runningCount > 0 && (
<span
data-testid="space-running-count"
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold tabular-nums"
style={{ background: runningStyle.bg, color: runningStyle.fg }}
title={t('rail.runningTitle', { count: runningCount })}
aria-label={t('rail.runningAria', { count: runningCount })}
>
{t('rail.running', { count: runningCount })}
</span>
)}
</button>
</div>
);
}