refactor: migrate agent graph to feature slice

This commit is contained in:
777genius 2026-04-14 16:24:09 +03:00
parent 3fddb4eafd
commit c8f9d9bbdd
30 changed files with 203 additions and 65 deletions

View file

@ -262,6 +262,12 @@ A smaller feature may skip `core/` and `preload/` when it is:
- not adding a new use case
- not adding a new transport boundary
If the feature still owns meaningful pure semantics or projection rules, keep
`core/` and skip only the process layers you do not need.
Example:
- `src/features/agent-graph` keeps `core/domain` and `renderer`, but does not add fake `main/` or `preload/` folders because the transport boundary lives elsewhere.
## Definition Of Done For A Reference Feature
A feature is reference-quality when:
@ -295,3 +301,15 @@ Use it as the example for:
- renderer dumb UI + hook orchestration
- browser-friendly transport direction
- feature-level lint guard rails
## Agent Graph As The Thin-Slice Reference
`src/features/agent-graph` is the thin-slice example for a renderer integration
feature built on top of a reusable package.
Use it as the example for:
- keeping pure graph semantics in `core/domain`
- exposing a renderer-only public entrypoint
- integrating `packages/agent-graph` without inventing fake process layers
- migrating legacy `src/renderer/features/*` code into the canonical feature root

View file

@ -227,6 +227,77 @@ export default defineConfig([
],
},
},
{
name: 'feature-agent-graph-public-entrypoints',
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/features/agent-graph/**/*'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'@features/agent-graph/core/**',
'@features/agent-graph/renderer/**',
],
message:
'Import agent-graph only through its public entrypoint: @features/agent-graph/renderer.',
},
],
},
],
},
},
{
name: 'feature-agent-graph-core-domain-guards',
files: ['src/features/agent-graph/core/domain/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'@features/agent-graph/renderer/**',
'@main/**',
'@renderer/**',
'@preload/**',
'electron',
'fastify',
'child_process',
'node:child_process',
],
message:
'agent-graph core/domain must stay pure and cannot depend on renderer, main, preload, or platform code.',
},
],
},
],
},
},
{
name: 'feature-agent-graph-renderer-boundaries',
files: ['src/features/agent-graph/renderer/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'@main/**',
'@preload/**',
'electron',
],
message:
'agent-graph renderer may depend on shared, renderer, package, and feature-local modules, but not on main/preload or Electron APIs directly.',
},
],
},
],
},
},
// Module boundaries - Enforce Electron three-process architecture
{

View file

@ -8,6 +8,7 @@ Before creating or refactoring a feature, read:
Reference implementation:
- `src/features/recent-projects`
- `src/features/agent-graph`
Use `src/features/<feature-name>/` by default when the work introduces:
- a new use case or business policy
@ -17,3 +18,7 @@ Use `src/features/<feature-name>/` by default when the work introduces:
Do not duplicate architecture rules in feature folders.
Keep the standard centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md).
Rule of thumb:
- `recent-projects` is the full slice example with process-aware outer layers
- `agent-graph` is the thin slice example built around `core/` plus `renderer/`

View file

@ -0,0 +1,20 @@
# Agent Graph Feature
This feature is a thin renderer slice over the reusable graph engine in `packages/agent-graph`.
Read first:
- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md)
- [Feature root guidance](../CLAUDE.md)
Public entrypoint:
- `@features/agent-graph/renderer`
Responsibilities:
- `packages/agent-graph` owns reusable graph rendering and low-level graph mechanics
- `src/features/agent-graph/core/domain` owns project-specific graph semantics and pure projection helpers
- `src/features/agent-graph/renderer` owns the renderer integration layer, hooks, adapters, and UI
Use this feature as the thin-slice example when a feature:
- has no dedicated `main` or `preload` transport boundary
- integrates an existing reusable package into the app shell
- still needs its own feature boundary and public entrypoint

View file

@ -160,7 +160,7 @@ export function buildInlineActivityEntries({
for (const [ownerNodeId, entries] of entriesByOwnerNodeId) {
entriesByOwnerNodeId.set(
ownerNodeId,
entries.sort((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp))
entries.toSorted((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp))
);
}

View file

@ -1,8 +1,9 @@
/**
* TeamGraphAdapter transforms Zustand TeamData GraphDataPort.
*
* This is the ONLY file in this feature that imports from @renderer/store.
* If the project data model changes, ONLY this class needs updating.
* This adapter owns the graph projection from team runtime state into the
* reusable package port model. Renderer hooks may still read store state, but
* projection rules stay here so the mapping logic has one main reason to change.
*
* Class-based with ES #private fields and DI-ready constructor.
*/
@ -26,16 +27,15 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import {
buildInlineActivityEntries,
getGraphLeadMemberName,
} from '../utils/buildInlineActivityEntries';
import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks';
} from '../../core/domain/buildInlineActivityEntries';
import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks';
import {
isTaskBlocked,
isTaskInReviewCycle,
resolveTaskReviewer,
} from '../utils/taskGraphSemantics';
} from '../../core/domain/taskGraphSemantics';
import type {
GraphActivityItem,
GraphDataPort,
GraphEdge,
GraphNode,
@ -571,7 +571,10 @@ export class TeamGraphAdapter {
for (const relatedId of task.related ?? []) {
if (!visibleTaskIds.has(relatedId)) continue;
const key = [task.id, relatedId].sort().join(':');
const key =
task.id.localeCompare(relatedId) <= 0
? `${task.id}:${relatedId}`
: `${relatedId}:${task.id}`;
if (this.#seenRelated.has(key)) continue;
this.#seenRelated.add(key);
edges.push({

View file

@ -13,7 +13,7 @@ import {
} from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
import { TeamGraphAdapter } from './TeamGraphAdapter';
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
import type { GraphDataPort } from '@claude-teams/agent-graph';

View file

@ -0,0 +1,12 @@
/**
* agent-graph renderer feature - public API.
*
* Consumers outside the feature should import from here instead of reaching
* into ui/, hooks/, or core/ directly.
*/
export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries';
export { TeamGraphAdapter } from './adapters/TeamGraphAdapter';
export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay';
export { TeamGraphOverlay } from './ui/TeamGraphOverlay';
export { TeamGraphTab } from './ui/TeamGraphTab';

View file

@ -17,7 +17,7 @@ import {
buildInlineActivityEntries,
getGraphLeadMemberName,
type InlineActivityEntry,
} from '../utils/buildInlineActivityEntries';
} from '../../core/domain/buildInlineActivityEntries';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';

View file

@ -1,7 +1,7 @@
/**
* GraphNodePopover renders popover for graph nodes using project UI components.
* Lives in features/ (not in package) so it CAN import from @renderer/.
* Reuses agentAvatarUrl, status helpers, and UI primitives from the project.
* This stays in the renderer slice instead of the reusable package because it
* composes project-specific UI, selectors, and presentation helpers.
*/
import { Badge } from '@renderer/components/ui/badge';
@ -16,7 +16,7 @@ import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvision
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { isTaskInReviewCycle, resolveTaskReviewer } from '../utils/taskGraphSemantics';
import { isTaskInReviewCycle, resolveTaskReviewer } from '../../core/domain/taskGraphSemantics';
import { GraphTaskCard } from './GraphTaskCard';
@ -40,11 +40,22 @@ function formatToolPreview(preview: string | undefined): string | undefined {
// If it looks like raw JSON object, try to extract a readable field
if (preview.startsWith('{') || preview.startsWith('[')) {
try {
const obj = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview);
// Common readable fields
return (
obj.subject ?? obj.name ?? obj.label ?? obj.file_path ?? obj.path ?? obj.query ?? undefined
);
const parsed: unknown = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const previewRecord = parsed as Record<string, unknown>;
const candidates = [
previewRecord.subject,
previewRecord.name,
previewRecord.label,
previewRecord.file_path,
previewRecord.path,
previewRecord.query,
];
const firstText = candidates.find((value) => typeof value === 'string');
if (typeof firstText === 'string') {
return firstText;
}
}
} catch {
// Truncated JSON — extract first quoted value
const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview);
@ -421,7 +432,7 @@ const MemberPopoverContent = ({
)}
</div>
{/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */}
{/* Context usage stays hidden for now because LeadContextUsage.percent is unreliable. */}
{/* Current task indicator — reuses same pattern as MemberCard */}
{node.currentTaskId && node.currentTaskSubject && (

View file

@ -1,6 +1,7 @@
/**
* GraphTaskCard wraps the REAL KanbanTaskCard with graph-specific glow/pulse effects.
* Lives in features/ so it CAN import from @renderer/.
* This is a renderer integration component, so it is allowed to compose
* project UI primitives and store-backed selectors.
*/
import { useMemo } from 'react';
@ -10,10 +11,10 @@ import { useStore } from '@renderer/store';
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
import { isTaskBlocked, resolveTaskGraphColumn } from '../utils/taskGraphSemantics';
import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
import type { KanbanColumnId, TeamTask } from '@shared/types';
// ─── Types ──────────────────────────────────────────────────────────────────
@ -119,8 +120,8 @@ export const GraphTaskCard = ({
const columnId = resolveColumn(task);
const taskWithKanban = task;
const closeAct = (fn?: (id: string) => void) => (taskId: string) => {
fn?.(taskId);
const closeAct = (fn?: (id: string) => void) => (nextTaskId: string) => {
fn?.(nextTaskId);
onClose();
};

View file

@ -9,13 +9,13 @@ import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useStore } from '@renderer/store';
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { GraphActivityHud } from './GraphActivityHud';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
import { GraphNodePopover } from './GraphNodePopover';
import { GraphProvisioningHud } from './GraphProvisioningHud';
import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog';
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
import type {

View file

@ -9,15 +9,15 @@ import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useStore } from '@renderer/store';
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { GraphActivityHud } from './GraphActivityHud';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
import { GraphNodePopover } from './GraphNodePopover';
import { GraphProvisioningHud } from './GraphProvisioningHud';
import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog';
import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph';
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
import type {
MemberActivityFilter,
MemberDetailTab,

View file

@ -3,8 +3,8 @@
* Uses CSS display-toggle to keep all tabs mounted (preserving state).
*/
import { TeamGraphTab } from '@features/agent-graph/renderer';
import { TabUIProvider } from '@renderer/contexts/TabUIContext';
import { TeamGraphTab } from '@renderer/features/agent-graph/ui/TeamGraphTab';
import { DashboardView } from '../dashboard/DashboardView';
import { ExtensionStoreView } from '../extensions/ExtensionStoreView';

View file

@ -72,15 +72,15 @@ import { TrashDialog } from './kanban/TrashDialog';
import { MemberDetailDialog } from './members/MemberDetailDialog';
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { ComponentProps, CSSProperties } from 'react';
const ProjectEditorOverlay = lazy(() =>
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
);
const TeamGraphOverlay = lazy(() =>
import('@renderer/features/agent-graph/ui/TeamGraphOverlay').then((m) => ({
import('@features/agent-graph/renderer').then((m) => ({
default: m.TeamGraphOverlay,
}))
);

View file

@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries';
import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { isLeadMember } from '@shared/utils/leadDetection';
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
import { api } from '@renderer/api';
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
import {
@ -8,7 +9,6 @@ import {
} from '@renderer/components/team/activity/activityMessageContext';
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
import { Button } from '@renderer/components/ui/button';
import { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';

View file

@ -1,9 +0,0 @@
/**
* agent-graph feature public API.
* Only exports UI components and adapter types.
*/
export { TeamGraphAdapter } from './adapters/TeamGraphAdapter';
export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay';
export { TeamGraphOverlay } from './ui/TeamGraphOverlay';
export { TeamGraphTab } from './ui/TeamGraphTab';

View file

@ -2,7 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GraphActivityHud } from '@renderer/features/agent-graph/ui/GraphActivityHud';
import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { InboxMessage } from '@shared/types/team';
@ -17,8 +17,13 @@ const teamState = {
tasks: [],
messages: [],
},
teamDataCacheByName: {
'demo-team': {
teamDataCacheByName: new Map<
string,
{ members: Record<string, unknown>[]; tasks: unknown[]; messages: unknown[] }
>([
[
'demo-team',
{
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', agentType: 'developer' },
@ -26,7 +31,8 @@ const teamState = {
tasks: [],
messages: [],
},
} as Record<string, { members: Array<Record<string, unknown>>; tasks: unknown[]; messages: unknown[] }>,
],
]),
teams: [],
};
@ -38,7 +44,7 @@ vi.mock('@renderer/store', () => ({
vi.mock('@renderer/store/slices/teamSlice', () => ({
selectTeamDataForName: (_state: typeof teamState, teamName: string) =>
teamState.teamDataCacheByName[teamName] ??
teamState.teamDataCacheByName.get(teamName) ??
(teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null),
}));
@ -79,7 +85,7 @@ vi.mock('@renderer/components/team/activity/activityMessageContext', () => ({
resolveMessageRenderProps: () => ({}),
}));
vi.mock('@renderer/features/agent-graph/utils/buildInlineActivityEntries', () => ({
vi.mock('@features/agent-graph/core/domain/buildInlineActivityEntries', () => ({
buildInlineActivityEntries: (...args: unknown[]) => buildInlineActivityEntries(...args),
getGraphLeadMemberName: () => 'team-lead',
}));

View file

@ -2,8 +2,8 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GraphBlockingEdgePopover } from '@features/agent-graph/renderer/ui/GraphBlockingEdgePopover';
import { useStore } from '@renderer/store';
import { GraphBlockingEdgePopover } from '@renderer/features/agent-graph/ui/GraphBlockingEdgePopover';
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';

View file

@ -13,11 +13,11 @@ vi.mock('@renderer/components/ui/button', () => ({
React.createElement('button', { type: 'button' }, children),
}));
vi.mock('@renderer/features/agent-graph/ui/GraphTaskCard', () => ({
vi.mock('@features/agent-graph/renderer/ui/GraphTaskCard', () => ({
GraphTaskCard: () => React.createElement('div', null, 'task-card'),
}));
import { GraphNodePopover } from '@renderer/features/agent-graph/ui/GraphNodePopover';
import { GraphNodePopover } from '@features/agent-graph/renderer/ui/GraphNodePopover';
import type { GraphNode } from '@claude-teams/agent-graph';

View file

@ -2,6 +2,8 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GraphProvisioningHud } from '@features/agent-graph/renderer/ui/GraphProvisioningHud';
const hookState = {
presentation: null as
| {
@ -44,8 +46,6 @@ vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({
),
}));
import { GraphProvisioningHud } from '@renderer/features/agent-graph/ui/GraphProvisioningHud';
const placement = { x: 120, y: 80, scale: 1, visible: true };
describe('GraphProvisioningHud', () => {

View file

@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter';
import { TeamGraphAdapter } from '@features/agent-graph/renderer/adapters/TeamGraphAdapter';
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
import type { GraphDataPort } from '@claude-teams/agent-graph';
@ -459,7 +459,7 @@ describe('TeamGraphAdapter particles', () => {
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(2);
expect(graph.particles.map((particle) => particle.kind).sort()).toEqual([
expect(graph.particles.map((particle) => particle.kind).toSorted((a, b) => a.localeCompare(b))).toEqual([
'inbox_message',
'task_comment',
]);

View file

@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
buildInlineActivityEntries,
getGraphLeadMemberName,
} from '@renderer/features/agent-graph/utils/buildInlineActivityEntries';
} from '@features/agent-graph/core/domain/buildInlineActivityEntries';
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';

View file

@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
collapseOverflowStacks,
collapseOverflowStacksWithMeta,
} from '@renderer/features/agent-graph/utils/collapseOverflowStacks';
} from '@features/agent-graph/core/domain/collapseOverflowStacks';
import type { GraphNode } from '@claude-teams/agent-graph';