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
@@ -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>
);
}