Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/web/src/app/console-tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@

/* ============ Shadows — elevation tokens(dark mode 自动 inset 高光) ============ */
--console-shadow-soft: var(--shadow-elevation-1);
/* Card lift — brown-tinted, used by dashboard cards across Mission Hub */
--console-card-shadow: 0 8px 22px oklch(0.22 0.02 50 / 0.04);
/* Rail (ActivityBar) active/hover lift — wider/softer than elevation-1, brown-tinted */
--console-rail-shadow: 0 5px 14px oklch(0.22 0.02 50 / 0.07);

Expand Down Expand Up @@ -198,6 +200,8 @@
[data-theme="dark"] {
/* Dark rail shadow — deeper, no brown tint to avoid muddy look on dark surfaces */
--console-rail-shadow: 0 5px 14px oklch(0 0 0 / 0.4), inset 0 1px 0 oklch(1 0 0 / 0.08);
/* Dark card lift — deeper, no brown tint */
--console-card-shadow: 0 8px 22px oklch(0 0 0 / 0.35), inset 0 1px 0 oklch(1 0 0 / 0.06);

/* Mission Control dark */
--mc-status-open-dot: #b8a88f;
Expand Down
127 changes: 127 additions & 0 deletions packages/web/src/components/__tests__/team-home-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { BacklogItem, CatId } from '@cat-cafe/shared';
import { describe, expect, it } from 'vitest';
import { adaptTeamHomeData } from '../mission-control/team-home/adapter';
import { teamHomeFixture } from '../mission-control/team-home/fixture';

function catId(id: string): CatId {
return id as CatId;
}

function makeBacklogItem(overrides: Partial<BacklogItem> = {}): BacklogItem {
const now = Date.UTC(2026, 5, 18, 9, 0, 0);
return {
id: 'backlog-f049',
userId: 'u_test',
title: '[F049] Mission Hub',
summary: 'Mission Hub work',
priority: 'p1',
tags: ['source:docs-backlog', 'feature:f049'],
status: 'dispatched',
createdBy: 'user',
createdAt: now - 10_000,
updatedAt: now - 1_000,
dispatchedAt: now - 5_000,
dispatchedThreadId: 'thread-f049',
dispatchedThreadPhase: 'coding',
audit: [{ id: 'audit-dispatched', action: 'dispatched', actor: { kind: 'user', id: 'u_test' }, timestamp: now }],
...overrides,
};
}

describe('adaptTeamHomeData', () => {
it('derives the active feature from docs backlog feature tags', () => {
const data = adaptTeamHomeData({ items: [makeBacklogItem()] });

expect(data.mission.activeFeatureId).toBe('F049');
expect(data.mission.truthSourceUrl).toBe('/api/backlog/feature-doc-detail?featureId=F049');
});

it('projects active lease and thread state into the baton and team cards', () => {
const acquiredAt = Date.UTC(2026, 5, 18, 9, 5, 0);
const heartbeatAt = Date.UTC(2026, 5, 18, 9, 20, 0);
const lastActiveAt = Date.UTC(2026, 5, 18, 9, 25, 0);
const item = makeBacklogItem({
title: 'Team Home MVP',
lease: {
ownerCatId: catId('codex'),
state: 'active',
acquiredAt,
heartbeatAt,
expiresAt: Date.UTC(2026, 5, 18, 10, 0, 0),
},
});

const data = adaptTeamHomeData({
items: [item],
threadsByBacklogId: {
[item.id]: {
lastActiveAt,
participants: [catId('codex'), catId('claude')],
},
},
});

expect(data.baton.holder).toBe('codex');
expect(data.baton.scope).toBe('Team Home MVP');
expect(data.baton.since).toBe(new Date(acquiredAt).toISOString());
expect(data.baton.nextStep).toBe('@codex 正在实现中');

expect(data.team.find((member) => member.id === 'codex')?.currentContext).toBe('持球:Team Home MVP');
expect(data.team.find((member) => member.id === 'codex')?.lastActiveAt).toBe(new Date(lastActiveAt).toISOString());
expect(data.team.find((member) => member.id === 'claude')?.currentContext).toBe('参与:Team Home MVP');
expect(data.team.find((member) => member.id === 'claude')?.lastActiveAt).toBe(new Date(lastActiveAt).toISOString());
});

it('does not treat audit lifecycle logs as evidence items', () => {
const data = adaptTeamHomeData({
items: [
makeBacklogItem({
audit: [
{ id: 'audit-created', action: 'created', actor: { kind: 'user', id: 'u_test' }, timestamp: 1 },
{ id: 'audit-dispatched', action: 'dispatched', actor: { kind: 'user', id: 'u_test' }, timestamp: 2 },
{ id: 'audit-heartbeat', action: 'lease_heartbeat', actor: { kind: 'cat', id: 'codex' }, timestamp: 3 },
],
}),
],
});

expect(data.missions[0]?.evidenceCount).toBeUndefined();
expect(data.missions[0]?.requiredEvidence).toBeUndefined();
});

it('prefers active lease over dispatched over approved regardless of item order', () => {
const approved = makeBacklogItem({
id: 'approved-1',
status: 'approved',
title: 'Approved Mission',
});
const dispatchedWithLease = makeBacklogItem({
id: 'dispatched-1',
status: 'dispatched',
title: 'Dispatched With Lease',
lease: {
ownerCatId: catId('claude'),
state: 'active',
acquiredAt: Date.UTC(2026, 5, 18, 9, 10, 0),
heartbeatAt: Date.UTC(2026, 5, 18, 9, 20, 0),
expiresAt: Date.UTC(2026, 5, 18, 10, 0, 0),
},
});

const data = adaptTeamHomeData({ items: [approved, dispatchedWithLease] });

expect(data.baton.scope).toBe('Dispatched With Lease');
expect(data.baton.holder).toBe('claude');
expect(data.mission.activeFeatureId).toBe('F049');
});

it('only treats approved or dispatched items as active work', () => {
const suggested = makeBacklogItem({ id: 'suggested-1', status: 'suggested' });
const done = makeBacklogItem({ id: 'done-1', status: 'done' });
const data = adaptTeamHomeData({ items: [suggested, done] });

expect(data.missions).toHaveLength(0);
expect(data.baton.scope).toBe(teamHomeFixture.baton.scope);
expect(data.mission.activeFeatureId).toBe('—');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ImportProjectModal } from './ImportProjectModal';
import { QuickCreateForm } from './QuickCreateForm';
import { SuggestionDrawer } from './SuggestionDrawer';
import { ThreadSituationPanel } from './ThreadSituationPanel';
import { TeamHomePanel } from './team-home/TeamHomePanel';
import { WorkflowSopPanel } from './WorkflowSopPanel';

interface BacklogListResponse {
Expand Down Expand Up @@ -470,6 +471,18 @@ export function MissionControlPage() {
)}

<div className="mt-4 flex console-divider-b">
<button
type="button"
onClick={() => setActiveTab('today')}
className={`px-5 py-2.5 text-sm font-semibold transition-colors ${
activeTab === 'today'
? 'rounded-lg bg-[var(--console-active-bg)] text-cafe'
: 'text-cafe-muted hover:text-cafe-secondary hover:bg-[var(--console-hover-bg)]'
}`}
data-testid="mc-tab-today"
>
今日团队
</button>
<button
type="button"
onClick={() => setActiveTab('features')}
Expand Down Expand Up @@ -541,6 +554,8 @@ export function MissionControlPage() {
<div className="min-h-0 flex-1 pt-4">
{activeProject ? (
<ExternalProjectTab project={activeProject} />
) : activeTab === 'today' ? (
<TeamHomePanel items={items} threadsByBacklogId={threadsByBacklogId} />
) : activeTab === 'features' ? (
<div className="grid min-h-0 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="space-y-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client';

import type { BacklogItem, CatId } from '@cat-cafe/shared';
import { useMemo } from 'react';
import { adaptTeamHomeData } from './adapter';
import { ActiveMissionsTable } from './blocks/ActiveMissionsTable';
import { BatonBoard } from './blocks/BatonBoard';
import { CultureConstitutionPanel } from './blocks/CultureConstitutionPanel';
import { CVODecisionQueue } from './blocks/CVODecisionQueue';
import { QualityGateChecklist } from './blocks/QualityGateChecklist';
import { RecentMemoryFeed } from './blocks/RecentMemoryFeed';
import { RiskWatch } from './blocks/RiskWatch';
import { SharedMissionBanner } from './blocks/SharedMissionBanner';
import { TeamStatusCards } from './blocks/TeamStatusCards';

interface TeamHomePanelProps {
items: BacklogItem[];
threadsByBacklogId?: Record<string, { lastActiveAt: number; participants: CatId[] }>;
}

export function TeamHomePanel({ items, threadsByBacklogId }: TeamHomePanelProps) {
const data = useMemo(() => adaptTeamHomeData({ items, threadsByBacklogId }), [items, threadsByBacklogId]);

return (
<div className="flex flex-col gap-4 lg:flex-row">
<div className="min-w-0 flex-1 space-y-4">
<SharedMissionBanner mission={data.mission} />
<BatonBoard baton={data.baton} />
<div className="grid gap-4 md:grid-cols-2">
<QualityGateChecklist gates={data.qualityGates} />
<TeamStatusCards members={data.team} />
</div>
<ActiveMissionsTable missions={data.missions} />
<CultureConstitutionPanel culture={data.culture} />
</div>
<aside className="w-full shrink-0 space-y-4 lg:w-80">
<CVODecisionQueue items={data.cvoDecisions} />
<RiskWatch risks={data.risks} />
<RecentMemoryFeed items={data.recentMemory} />
</aside>
</div>
);
}
Loading
Loading