diff --git a/ui/src/components/spaces/SpaceRail.tsx b/ui/src/components/spaces/SpaceRail.tsx index 1d0122a..cfa8f4c 100644 --- a/ui/src/components/spaces/SpaceRail.tsx +++ b/ui/src/components/spaces/SpaceRail.tsx @@ -44,6 +44,13 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) { 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 が全ユーザーのスペースを @@ -95,7 +102,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) { space={s} active={s.id === selectedId} onSelect={onSelect} - runningCount={countRunningTasksForSpace(tasks ?? [], s, myUserId)} + runningCount={countRunningTasksForSpace(tasks ?? [], s, { viewerId: myUserId, isAdmin, caseSpaceIds })} mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId} /> ))} diff --git a/ui/src/content/help/00-changelog.md b/ui/src/content/help/00-changelog.md index 72ddb7d..fcb7ab4 100644 --- a/ui/src/content/help/00-changelog.md +++ b/ui/src/content/help/00-changelog.md @@ -12,6 +12,10 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に > 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。 +## 2026-06-29 — 個人ワークスペースの「実行中」バッジを監視範囲とそろえた(管理者) + +左のワークスペース一覧に出る緑の「● N 実行中」バッジが、管理者の個人ワークスペースで、監視できる他ユーザーの個人ワークスペースの実行中タスクを数え落としていた不具合を修正しました。「他のメンバー」タブで見える実行中タスクと、バッジの件数が一致するようになりました。一般ユーザーのバッジは従来どおり自分のぶんだけを数えます(→[ワークスペース](./21-workspaces.md))。 + ## 2026-06-29 — ワーカー / GPU の「使用中」に種別バッジを追加(管理者のみ) ワーカー一覧の「使用中: …」表示に、いま動いているジョブの**種別バッジ**が付きました。学習(reflection)など通常タスク以外で GPU が埋まっているときに、ユーザー名のとなりに `reflection` のようなタグが出ます。通常タスク(agent)にはバッジを付けないので、「reflection で埋まっているだけ」を一目で見分けられます。これも管理者にだけ見えます(→[管理者向け](./19-admin.md))。 diff --git a/ui/src/lib/spaceTasks.test.ts b/ui/src/lib/spaceTasks.test.ts index 77c681f..2513938 100644 --- a/ui/src/lib/spaceTasks.test.ts +++ b/ui/src/lib/spaceTasks.test.ts @@ -110,19 +110,38 @@ describe('countRunningTasksForSpace', () => { expect(countRunningTasksForSpace(tasks, personalSpace)).toBe(2); }); - it('scopes the personal badge to the viewer when viewerId is given (admin sees all rows)', () => { - // admin のリスト API は全件 (1=1) を返すため、owner 絞りが無いと他ユーザーの - // null タスクが個人スペースのバッジに混ざる。viewerId 指定で本人のみ数える。 + it('scopes the personal badge to the viewer for non-admins', () => { + // 非 admin: 共有されない限り他人を数えない。本人の null + 本人の個人スペース id のみ。 const tasks = [ task(1, null, '2026-01-01', 'running', 'u1'), // 自分 task(2, null, '2026-01-02', 'running', 'u2'), // 他ユーザー → 除外 task(3, 'personal', '2026-01-03', 'running', 'u1'), // 自分の個人スペース id ]; - expect(countRunningTasksForSpace(tasks, personalSpace, 'u1')).toBe(2); + expect(countRunningTasksForSpace(tasks, personalSpace, { viewerId: 'u1' })).toBe(2); // viewerId 未指定なら従来どおり全件 (no-auth 単独利用)。 expect(countRunningTasksForSpace(tasks, personalSpace)).toBe(3); }); + it('counts the whole personal bucket for admins (monitoring), incl. other users\' concrete personal ids', () => { + // admin の個人スペースバッジは「他のメンバー」タブと同じ母集団を数える。 + // 個人バケツ = null または case でない space_id(= 誰かの個人スペース)。 + const tasks = [ + task(1, null, '2026-01-01', 'running', 'u1'), // 自分 legacy null + task(2, 'personal', '2026-01-02', 'running', 'u1'), // 自分の個人スペース id + task(3, 'personal-u2', '2026-01-03', 'running', 'u2'), // 他ユーザーの個人スペース(具体 id) + task(4, 'sp-a', '2026-01-04', 'running', 'u2'), // case スペース → 除外 + task(5, 'personal-u3', '2026-01-05', 'queued', 'u3'), // running でない → 除外 + ]; + const caseSpaceIds = new Set(['sp-a']); + expect( + countRunningTasksForSpace(tasks, personalSpace, { viewerId: 'u1', isAdmin: true, caseSpaceIds }), + ).toBe(3); + // 同じ集合でも非 admin なら本人(u1)のみ。 + expect( + countRunningTasksForSpace(tasks, personalSpace, { viewerId: 'u1', isAdmin: false, caseSpaceIds }), + ).toBe(2); + }); + it('ignores tasks with no latestJob', () => { const tasks = [task(1, 'sp-a', '2026-01-01'), task(2, 'sp-a', '2026-01-02', 'running')]; expect(countRunningTasksForSpace(tasks, caseSpace)).toBe(1); diff --git a/ui/src/lib/spaceTasks.ts b/ui/src/lib/spaceTasks.ts index 77440d5..3439611 100644 --- a/ui/src/lib/spaceTasks.ts +++ b/ui/src/lib/spaceTasks.ts @@ -34,25 +34,37 @@ export function filterTasksForSpace( /** * 指定スペースに属し、いま実行中 (latestJob.status === 'running') のタスク件数。 - * null の space_id は個人スペースに属するものとして数える (filterTasksForSpace と同じ規則)。 * リスト API はスペースで絞らないため、クライアント保持の全件から数える。 * - * 個人スペースのバッジは**閲覧者本人の実行中のみ**数える。admin はリスト API が全件 - * (1=1) を返すため、owner 絞りをしないと他ユーザーの null タスクがレールのバッジに - * 混ざってしまう。`viewerId` を渡すと個人スペースで owner 一致のみに絞る。未指定 - * (no-auth 単独利用) は全件が本人のものなので従来どおり絞らない。 + * **バッジの母集団は「チャット一覧で見えるタスク」と一致させる**(ずれると実態と合わない): + * - 案件スペース: `space_id === space.id` のみ。 + * - 個人スペース: 個人バケツ(`space_id == null` または case でない space_id = 誰かの + * 個人スペース)を母集団とし、`filterTasksForSpace` の個人スペース判定と揃える。 + * - 非 admin: 本人所有のみ(共有されない限り他人を数えない)。 + * - admin: 個人バケツの全員ぶん(「他のメンバー」で監視できるものをそのまま数える)。 + * + * `opts.caseSpaceIds`(閲覧可能な case スペースの id 集合)未指定時は後方互換で + * 「この個人スペース or null」を母集団にする。`opts.isAdmin` 既定 false / `opts.viewerId` + * 未指定 (no-auth 単独利用) は owner 絞りをしない。 */ export function countRunningTasksForSpace( tasks: LocalTask[], space: { id: string; kind: 'personal' | 'case' }, - viewerId?: string | null, + opts?: { viewerId?: string | null; isAdmin?: boolean; caseSpaceIds?: ReadonlySet }, ): number { const isPersonal = space.kind === 'personal'; + const viewerId = opts?.viewerId ?? null; + const isAdmin = opts?.isAdmin ?? false; + const caseSpaceIds = opts?.caseSpaceIds; return tasks.filter((t) => { if (t.latestJob?.status !== 'running') return false; - const inSpace = t.spaceId === space.id || (isPersonal && t.spaceId == null); - if (!inSpace) return false; - if (isPersonal && viewerId != null && t.ownerId !== viewerId) return false; + if (!isPersonal) return t.spaceId === space.id; + const inBucket = + t.spaceId == null || + (caseSpaceIds ? !caseSpaceIds.has(t.spaceId) : t.spaceId === space.id); + if (!inBucket) return false; + // 個人スペース: 非adminは本人のみ。admin は監視のため個人バケツ全員を数える。 + if (!isAdmin && viewerId != null && t.ownerId !== viewerId) return false; return true; }).length; }