# Directory Structure
```
├── .cursor
│ └── rules
│ ├── solution-exploration-guide.mdc
│ ├── task-plannning.mdc
│ └── task-status-update-rule.mdc
├── .gitignore
├── .npmignore
├── coverage
│ ├── clover.xml
│ ├── coverage-final.json
│ ├── lcov-report
│ │ ├── base.css
│ │ ├── block-navigation.js
│ │ ├── favicon.png
│ │ ├── index.html
│ │ ├── prettify.css
│ │ ├── prettify.js
│ │ ├── sort-arrow-sprite.png
│ │ └── sorter.js
│ └── lcov.info
├── jest.config.js
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── aggregates
│ │ └── workplan.ts
│ ├── index.ts
│ ├── prompts.ts
│ ├── tools
│ │ ├── common.ts
│ │ ├── index.ts
│ │ ├── plan
│ │ │ └── toolDefs.ts
│ │ ├── track
│ │ │ └── toolDefs.ts
│ │ └── update
│ │ └── toolDefs.ts
│ ├── utils
│ │ ├── fileStorage.ts
│ │ └── logger.ts
│ └── values
│ ├── commit.ts
│ ├── common.ts
│ ├── pullRequest.ts
│ ├── status.ts
│ └── ticket.ts
├── tsconfig.json
└── visualization
├── .gitignore
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── public
│ └── vite.svg
├── README.md
├── src
│ ├── App.css
│ ├── App.tsx
│ ├── assets
│ │ └── react.svg
│ ├── components
│ │ ├── CommitNode.tsx
│ │ ├── ExportPanel.tsx
│ │ ├── FilterPanel.tsx
│ │ ├── nodes
│ │ │ └── nodes.css
│ │ ├── OrientationWarning.tsx
│ │ └── WorkplanFlow.tsx
│ ├── index.css
│ ├── main.tsx
│ ├── types.ts
│ ├── utils
│ │ ├── exportUtils.ts
│ │ ├── responsiveUtils.ts
│ │ ├── storageUtils.ts
│ │ └── workplanConverter.ts
│ └── vite-env.d.ts
├── tailwind.config.cjs
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
```
# Files
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
```
1 | # Source files
2 | src/
3 |
4 | # Tests
5 | test/
6 | coverage/
7 | *.test.js
8 | *.spec.js
9 |
10 | # Build tools
11 | tsconfig.json
12 | .github/
13 | .vscode/
14 | .cursor/
15 |
16 | # Misc
17 | .git/
18 | .gitignore
19 | node_modules/
20 | npm-debug.log
21 | visualization/
22 |
23 | # Environment
24 | .env
25 | .env.*
26 |
27 | # Documentation (except README)
28 | docs/
```
--------------------------------------------------------------------------------
/visualization/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Project specific files
27 | public/data/workplan.json
28 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependency directories
2 | node_modules/
3 | dist/
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Environment variables
19 | .env
20 | .env.local
21 | .env.development.local
22 | .env.test.local
23 | .env.production.local
24 |
25 | # IDE files
26 | .idea/
27 | .vscode/
28 | *.swp
29 | *.swo
30 |
31 | # OS files
32 | .DS_Store
33 | Thumbs.db
34 |
35 | # Data files
36 | visualization/public/data/workplan.json
37 | visualization/public/data/workplan.json
38 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | [](https://www.npmjs.com/package/@yodakeisuke/mcp-micromanage)
2 |
3 | # mcp-micromanage
4 |
5 | Control your coding agent colleague who tends to go off track.
6 |
7 | If [sequentialthinking](https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking) is a dynamic formulation and externalization of thought workflows, this tool is a dynamic formulation and externalization of development task flows.
8 |
9 |
10 | 
11 |
12 | ## Motivation
13 |
14 | ### Challenges with Coding Agents
15 | - Coding agents often make modifications beyond what they're asked to do
16 | - Assuming cursor+claude
17 | - They struggle to request user feedback at key decision points during implementation
18 | - Work plans and progress tracking can be challenging to visualize and monitor
19 |
20 | ### Solution
21 | - **Commit and PR-based Work Plans**: Force implementation plans that break down tickets into PRs and commits as the minimum units of work
22 | - **Forced Frequent Feedback**: Enforce user reviews at the commit level, creating natural checkpoints for feedback
23 | - **Visualization**: Instantly understand the current work plan and implementation status through a local React app
24 |
25 | ## tool
26 |
27 | 1. **plan**: Define your implementation plan with PRs and commits
28 | 2. **track**: Monitor progress and current status of all work items
29 | 3. **update**: Change status as work progresses, with mandatory user reviews
30 |
31 | ## Visualization Dashboard
32 |
33 | The project includes a React-based visualization tool that provides:
34 |
35 | - Hierarchical view of PRs and commits
36 | - Real-time updates with auto-refresh
37 | - Status-based color coding
38 | - Zoom and pan capabilities
39 |
40 | ## Getting Started
41 |
42 | ### Headless(mcp tool only)
43 |
44 | 1. Add to your mcp json
45 | ```json
46 | {
47 | "mcpServers": {
48 | "micromanage": {
49 | "command": "npx",
50 | "args": [
51 | "-y",
52 | "@yodakeisuke/mcp-micromanage"
53 | ]
54 | }
55 | }
56 | }
57 | ```
58 |
59 | 2. (Highly recommended) Add the following `.mdc`s to your project
60 |
61 | [recommended-rules](https://github.com/yodakeisuke/mcp-micromanage-your-agent/tree/main/.cursor/rules)
62 |
63 | (Can be adjusted to your preference)
64 |
65 | ### With Visualisation
66 |
67 | 1. clone this repository
68 |
69 | 2. Add to your mcp json
70 | ```json
71 | {
72 | "mcpServers": {
73 | "micromanage": {
74 | "command": "node",
75 | "args": [
76 | "[CLONE_DESTINATION_PATH]/sequentialdeveloping/dist/index.js"
77 | ]
78 | }
79 | }
80 | }
81 | ```
82 |
83 | 3. build server
84 | ```bash
85 | npm install
86 | npm run build
87 | ```
88 |
89 | 4. run frontend
90 | ```bash
91 | cd visualization/
92 | npm install
93 | npm run dev
94 | ```
95 |
96 | ## License
97 |
98 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
99 |
100 | ### Third-Party Software
101 |
102 | This project uses the following third-party software:
103 |
104 | - **MCP TypeScript SDK**: Licensed under the MIT License. Copyright © 2023-2025 Anthropic, PBC.
105 |
106 | ## Acknowledgments
107 |
108 | - Built with [MCP (Model Context Protocol)](https://github.com/modelcontextprotocol/typescript-sdk)
109 | - Maintained by [yodakeisuke](https://github.com/yodakeisuke)
110 |
```
--------------------------------------------------------------------------------
/visualization/src/vite-env.d.ts:
--------------------------------------------------------------------------------
```typescript
1 | /// <reference types="vite/client" />
2 |
```
--------------------------------------------------------------------------------
/visualization/postcss.config.cjs:
--------------------------------------------------------------------------------
```
1 | module.exports = {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | autoprefixer: {},
5 | },
6 | }
```
--------------------------------------------------------------------------------
/visualization/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
```
--------------------------------------------------------------------------------
/src/values/common.ts:
--------------------------------------------------------------------------------
```typescript
1 | export type Status =
2 | | "not_started"
3 | | "in_progress"
4 | | "user_review"
5 | | "completed"
6 | | "cancelled"
7 | | "needsRefinment";
```
--------------------------------------------------------------------------------
/visualization/tailwind.config.cjs:
--------------------------------------------------------------------------------
```
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./index.html",
5 | "./src/**/*.{js,ts,jsx,tsx}",
6 | ],
7 | theme: {
8 | extend: {},
9 | },
10 | plugins: [],
11 | }
```
--------------------------------------------------------------------------------
/src/tools/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | export { PLAN_TOOL } from './plan/toolDefs.js';
2 | export { TRACK_TOOL } from './track/toolDefs.js';
3 | export { UPDATE_STATUS_TOOL } from './update/toolDefs.js';
4 |
5 |
6 | export * from './common.js';
```
--------------------------------------------------------------------------------
/visualization/src/main.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import React, { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import App from './App.tsx'
4 | import './index.css'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 | <StrictMode>
8 | <App />
9 | </StrictMode>,
10 | )
11 |
```
--------------------------------------------------------------------------------
/src/values/commit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Status } from './status.js';
2 |
3 | export type CommitId = string;
4 |
5 | export type Commit = {
6 | commitId?: CommitId;
7 | goal: string;
8 | status: Status;
9 | needsMoreThoughts?: boolean;
10 | needsRevision?: boolean;
11 | revisesTargetCommit?: CommitId;
12 | developerNote?: string;
13 | };
```
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
```javascript
1 | export default {
2 | transform: {},
3 | extensionsToTreatAsEsm: ['.ts'],
4 | moduleNameMapper: {
5 | '^(\\.{1,2}/.*)\\.js$': '$1',
6 | },
7 | testEnvironment: 'node',
8 | testMatch: ['**/*.test.js'],
9 | verbose: true,
10 | collectCoverage: true,
11 | collectCoverageFrom: ['src/**/*.js', '!src/tests/**'],
12 | transformIgnorePatterns: [],
13 | };
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "NodeNext",
5 | "moduleResolution": "NodeNext",
6 | "esModuleInterop": true,
7 | "outDir": "./dist",
8 | "strict": true,
9 | "skipLibCheck": true,
10 | "forceConsistentCasingInFileNames": true
11 | },
12 | "include": ["src/**/*"],
13 | "exclude": ["node_modules", "dist", "requirements"]
14 | }
15 |
```
--------------------------------------------------------------------------------
/visualization/index.html:
--------------------------------------------------------------------------------
```html
1 | <!doctype html>
2 | <html lang="en">
3 | <head>
4 | <meta charset="UTF-8" />
5 | <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6 | <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 | <title>Vite + React + TS</title>
8 | </head>
9 | <body>
10 | <div id="root"></div>
11 | <script type="module" src="/src/main.tsx"></script>
12 | </body>
13 | </html>
14 |
```
--------------------------------------------------------------------------------
/visualization/tsconfig.node.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
```
--------------------------------------------------------------------------------
/visualization/eslint.config.js:
--------------------------------------------------------------------------------
```javascript
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
```
--------------------------------------------------------------------------------
/visualization/tsconfig.app.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true,
24 |
25 | /* Path Aliases */
26 | "baseUrl": ".",
27 | "paths": {
28 | "@/*": ["src/*"],
29 | "@data/*": ["../data/*"]
30 | },
31 | "resolveJsonModule": true
32 | },
33 | "include": ["src", "../data/*.json"]
34 | }
35 |
```
--------------------------------------------------------------------------------
/visualization/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "visualization",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview",
11 | "test:responsive": "node test/responsive-tests.js"
12 | },
13 | "dependencies": {
14 | "@tailwindcss/postcss": "^4.0.15",
15 | "autoprefixer": "^10.4.21",
16 | "postcss": "^8.5.3",
17 | "react": "^19.0.0",
18 | "react-dom": "^19.0.0",
19 | "reactflow": "^11.11.4",
20 | "tailwindcss": "^4.0.15"
21 | },
22 | "devDependencies": {
23 | "@eslint/js": "^9.21.0",
24 | "@types/react": "^19.0.10",
25 | "@types/react-dom": "^19.0.4",
26 | "@vitejs/plugin-react": "^4.3.4",
27 | "eslint": "^9.21.0",
28 | "eslint-plugin-react-hooks": "^5.1.0",
29 | "eslint-plugin-react-refresh": "^0.4.19",
30 | "globals": "^15.15.0",
31 | "puppeteer": "^21.7.0",
32 | "typescript": "~5.7.2",
33 | "typescript-eslint": "^8.24.1",
34 | "vite": "^6.2.0"
35 | }
36 | }
37 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@yodakeisuke/mcp-micromanage",
3 | "version": "0.1.3",
4 | "description": "MCP server for sequential development task management",
5 | "license": "MIT",
6 | "author": "yoda keisuke",
7 | "type": "module",
8 | "bin": {
9 | "mcp-micromanage": "dist/index.js"
10 | },
11 | "files": [
12 | "dist"
13 | ],
14 | "scripts": {
15 | "build": "tsc && shx chmod +x dist/*.js",
16 | "prepare": "npm run build",
17 | "watch": "tsc --watch",
18 | "test": "NODE_OPTIONS=--experimental-vm-modules jest"
19 | },
20 | "dependencies": {
21 | "@modelcontextprotocol/sdk": "1.7.0",
22 | "chalk": "^5.3.0",
23 | "cors": "^2.8.5",
24 | "express": "^5.0.1"
25 | },
26 | "devDependencies": {
27 | "@types/cors": "^2.8.17",
28 | "@types/express": "^5.0.1",
29 | "@types/jest": "^29.5.14",
30 | "@types/node": "^22",
31 | "jest": "^29.7.0",
32 | "shx": "^0.3.4",
33 | "typescript": "^5.6.3"
34 | },
35 | "keywords": [
36 | "mcp",
37 | "agent",
38 | "micromanage",
39 | "ai",
40 | "claude",
41 | "cursor"
42 | ],
43 | "publishConfig": {
44 | "access": "public"
45 | }
46 | }
47 |
```
--------------------------------------------------------------------------------
/visualization/src/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ReactNode } from 'react';
2 | import { Node, Edge as ReactFlowEdge } from 'reactflow';
3 |
4 | // Possible commit statuses
5 | export type CommitStatus = 'not_started' | 'in_progress' | 'completed' | 'cancelled' | 'needsRefinment' | 'user_review';
6 |
7 | // Node data type definition
8 | export interface NodeData {
9 | label: ReactNode; // Node label
10 | description?: string; // Node description
11 | status?: CommitStatus; // Node status
12 | statusLabel?: string; // Status label
13 | statusIcon?: string; // Status icon
14 | onEdit?: (data: NodeData) => void; // Callback when edit button is clicked
15 | onStatusChange?: (data: NodeData) => void; // Callback when status change button is clicked
16 | }
17 |
18 | // Extended node type
19 | export type ExtendedNode<T = any> = Node<T>;
20 |
21 | // Edge type
22 | export type Edge = ReactFlowEdge;
23 |
24 | // Commit plan type
25 | export interface CommitPlan {
26 | goal: string;
27 | status?: CommitStatus;
28 | developerNote?: string; // Developer implementation notes captured during refinement
29 | }
30 |
31 | // PR plan type
32 | export interface PRPlan {
33 | goal: string;
34 | commitPlans: CommitPlan[];
35 | status?: CommitStatus;
36 | developerNote?: string; // Developer implementation notes captured during refinement
37 | }
38 |
39 | // Work plan type
40 | export interface WorkPlan {
41 | goal: string;
42 | prPlans: PRPlan[];
43 | }
```
--------------------------------------------------------------------------------
/visualization/public/vite.svg:
--------------------------------------------------------------------------------
```
1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
```
--------------------------------------------------------------------------------
/src/values/ticket.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Status } from './status.js';
2 | import { PullRequest } from './pullRequest.js';
3 | import { PlanTaskInput } from '../aggregates/workplan.js';
4 |
5 | export type Ticket = {
6 | goal: string;
7 | pullRequests: PullRequest[];
8 | needsMoreThoughts?: boolean;
9 | };
10 |
11 | // Helper function to check if ticket exists
12 | export const ensureTicketExists = (
13 | ticketState: Ticket | "noTicket",
14 | isError = false
15 | ): { result: true; ticket: Ticket } | { result: false; response: { content: Array<{ type: string; text: string }>; isError?: boolean } } => {
16 | if (ticketState === "noTicket") {
17 | return {
18 | result: false,
19 | response: {
20 | content: [{
21 | type: "text",
22 | text: JSON.stringify({
23 | message: "No implementation plan found. Create an implementation plan first using the 'plan' tool.",
24 | ...(isError && { status: 'failed' })
25 | }, null, 2)
26 | }],
27 | isError
28 | }
29 | };
30 | }
31 |
32 | return {
33 | result: true,
34 | ticket: ticketState
35 | };
36 | };
37 |
38 | // Create a plan for a ticket from input data
39 | export const planTicket = (data: PlanTaskInput): Ticket => {
40 | // PRプランからPullRequestオブジェクトを作成
41 | const pullRequests = data.prPlans.map(prPlan => {
42 | // コミットプランからCommitオブジェクトを作成
43 | const commits = prPlan.commitPlans.map(commitPlan => ({
44 | goal: commitPlan.goal,
45 | status: "not_started" as Status,
46 | developerNote: commitPlan.developerNote
47 | }));
48 |
49 | return {
50 | goal: prPlan.goal,
51 | status: "not_started" as Status,
52 | commits,
53 | developerNote: prPlan.developerNote
54 | };
55 | });
56 |
57 | // チケットを作成して返す
58 | return {
59 | goal: data.goal,
60 | pullRequests,
61 | needsMoreThoughts: data.needsMoreThoughts
62 | };
63 | };
```
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
```typescript
1 | import chalk from 'chalk';
2 |
3 |
4 | export enum LogLevel {
5 | DEBUG = 0,
6 | INFO = 1,
7 | WARN = 2,
8 | ERROR = 3,
9 | NONE = 4
10 | }
11 |
12 | // 現在のログレベル(デフォルトはINFO)
13 | let currentLogLevel = LogLevel.INFO;
14 |
15 | export function setLogLevel(level: LogLevel): void {
16 | currentLogLevel = level;
17 | }
18 |
19 | export function getLogLevel(): LogLevel {
20 | return currentLogLevel;
21 | }
22 |
23 | function getTimestamp(): string {
24 | const now = new Date();
25 | return `[${now.toISOString().replace('T', ' ').substring(0, 19)}]`;
26 | }
27 |
28 | export function debug(message: string, ...args: any[]): void {
29 | if (currentLogLevel <= LogLevel.DEBUG) {
30 | console.error(`${getTimestamp()} ${chalk.blue('DEBUG')} ${message}`, ...args);
31 | }
32 | }
33 |
34 | export function info(message: string, ...args: any[]): void {
35 | if (currentLogLevel <= LogLevel.INFO) {
36 | console.error(`${getTimestamp()} ${chalk.green('INFO')} ${message}`, ...args);
37 | }
38 | }
39 |
40 | export function warn(message: string, ...args: any[]): void {
41 | if (currentLogLevel <= LogLevel.WARN) {
42 | console.warn(`${getTimestamp()} ${chalk.yellow('WARN')} ${message}`, ...args);
43 | }
44 | }
45 |
46 | export function error(message: string, ...args: any[]): void {
47 | if (currentLogLevel <= LogLevel.ERROR) {
48 | console.error(`${getTimestamp()} ${chalk.red('ERROR')} ${message}`, ...args);
49 | }
50 | }
51 |
52 | export function logError(message: string, error: unknown): void {
53 | if (currentLogLevel <= LogLevel.ERROR) {
54 | console.error(`${getTimestamp()} ${chalk.red('ERROR')} ${message}: ${error instanceof Error ? error.message : String(error)}`);
55 | if (error instanceof Error && error.stack) {
56 | console.error(`${getTimestamp()} ${chalk.red('STACK')} ${error.stack}`);
57 | }
58 | }
59 | }
60 |
61 | // デフォルトエクスポート
62 | export default {
63 | setLogLevel,
64 | getLogLevel,
65 | debug,
66 | info,
67 | warn,
68 | error,
69 | logError,
70 | LogLevel
71 | };
```
--------------------------------------------------------------------------------
/visualization/src/components/OrientationWarning.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import React, { useState, useEffect } from 'react';
2 | import { useBreakpoint, Breakpoint } from '../utils/responsiveUtils';
3 |
4 | /**
5 | * Component that detects mobile device orientation and displays a warning
6 | * Shows a recommendation to use landscape orientation
7 | */
8 | const OrientationWarning: React.FC = () => {
9 | const breakpoint = useBreakpoint();
10 | const [isPortrait, setIsPortrait] = useState(false);
11 | const [isDismissed, setIsDismissed] = useState(false);
12 |
13 | // Detect screen orientation
14 | useEffect(() => {
15 | const checkOrientation = () => {
16 | if (window.matchMedia("(orientation: portrait)").matches) {
17 | setIsPortrait(true);
18 | } else {
19 | setIsPortrait(false);
20 | }
21 | };
22 |
23 | // Initial check
24 | checkOrientation();
25 |
26 | // Orientation change listener
27 | window.addEventListener('resize', checkOrientation);
28 |
29 | // Cleanup
30 | return () => {
31 | window.removeEventListener('resize', checkOrientation);
32 | };
33 | }, []);
34 |
35 | // Determine whether to show the warning
36 | const shouldShowWarning = isPortrait && ['xs', 'sm'].includes(breakpoint) && !isDismissed;
37 |
38 | if (!shouldShowWarning) {
39 | return null;
40 | }
41 |
42 | return (
43 | <div className="fixed bottom-0 left-0 right-0 bg-yellow-100 border-t border-yellow-200 p-3 z-50 shadow-lg">
44 | <div className="flex justify-between items-center">
45 | <div className="flex items-center">
46 | <span className="text-xl mr-2" aria-hidden="true">📱</span>
47 | <div>
48 | <p className="text-yellow-800 font-medium">Landscape orientation recommended</p>
49 | <p className="text-yellow-700 text-xs">Rotate your device to landscape for a better experience.</p>
50 | </div>
51 | </div>
52 | <button
53 | onClick={() => setIsDismissed(true)}
54 | className="text-yellow-700 hover:text-yellow-900"
55 | aria-label="Close warning"
56 | >
57 | ✕
58 | </button>
59 | </div>
60 | </div>
61 | );
62 | };
63 |
64 | export default OrientationWarning;
```
--------------------------------------------------------------------------------
/src/tools/track/toolDefs.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Tool, createErrorResponse } from '../common.js';
2 | import { workPlan } from '../../index.js';
3 | import logger from '../../utils/logger.js';
4 | import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
5 |
6 | export const TRACK_TOOL: Tool<{}> = {
7 | name: "track",
8 | description: `
9 | This tool helps you monitor the current state of the implementation plan, view progress, and identify possible next steps.
10 | There is always exactly one task in either the in_progress or user_review state.
11 | **IMPORTANT**: There is always exactly one task in either the in_progress or user_review or needsRefinment state.
12 |
13 | **MANDATORY STATUS TRANSITION RULES:**
14 | needsRefinment → in_progress:
15 | - Requirements, implementation plan, and impact MUST be clearly explained.
16 | user_review → completed:
17 | - A commit may not transition to "completed" without explicit user approval. No exceptions.
18 |
19 | **After receiving the tool response, you MUST**::
20 | - Monitor PRs, commits, and overall progress to spot blockers or items needing refinement.
21 | - Review recommended actions to decide your next steps.
22 | - **Absolutely follow the content of "agentInstruction" in the response JSON**!!.
23 | `,
24 | schema: {},
25 | handler: async (_, extra: RequestHandlerExtra) => {
26 | try {
27 | logger.info('Track tool called');
28 |
29 | if (!workPlan) {
30 | logger.error('WorkPlan instance is not available');
31 | return createErrorResponse('WorkPlan instance is not initialized');
32 | }
33 |
34 | if (!workPlan.isInitialized()) {
35 | logger.error('WorkPlan is not initialized');
36 | return createErrorResponse('WorkPlan is not ready. Server initialization incomplete.');
37 | }
38 |
39 | const result = workPlan.trackProgress();
40 |
41 | return {
42 | content: result.content.map(item => ({
43 | type: 'text' as const,
44 | text: item.text
45 | })),
46 | isError: result.isError
47 | };
48 | } catch (error) {
49 | logger.logError('Error in TRACK tool', error);
50 | return createErrorResponse(error);
51 | }
52 | }
53 | };
```
--------------------------------------------------------------------------------
/src/tools/common.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
2 | import { z } from 'zod';
3 | import { type PlanTaskInput, type UpdateStatusInput, type WorkPlanInitOptions } from '../aggregates/workplan.js';
4 | import logger from '../utils/logger.js';
5 |
6 |
7 | export type Tool<Args extends z.ZodRawShape> = {
8 | name: string;
9 | description: string;
10 | schema: Args;
11 | handler: (
12 | args: z.infer<z.ZodObject<Args>>,
13 | extra: RequestHandlerExtra,
14 | ) =>
15 | | Promise<{
16 | content: Array<{
17 | type: 'text';
18 | text: string;
19 | }>;
20 | isError?: boolean;
21 | }>
22 | | {
23 | content: Array<{
24 | type: 'text';
25 | text: string;
26 | }>;
27 | isError?: boolean;
28 | };
29 | };
30 |
31 | /**
32 | * Enhanced error response generator with improved formatting and guidance.
33 | *
34 | * Formats error messages to be more user-friendly and provides specific guidance
35 | * for validation errors and other common issues.
36 | *
37 | * @param error - The error to format
38 | * @param context - Optional context about the error source (e.g., 'plan', 'update')
39 | * @returns Formatted error response
40 | */
41 | export function createErrorResponse(
42 | error: unknown,
43 | context?: string
44 | ): {
45 | content: Array<{ type: 'text'; text: string }>;
46 | isError: boolean;
47 | } {
48 | // Extract base error message
49 | const errorMessage = error instanceof Error ? error.message : String(error);
50 | logger.error(`Creating error response: ${errorMessage}`);
51 |
52 | // Format error message with guidance based on error type
53 | let formattedError = errorMessage;
54 |
55 | // Check if it's a validation error
56 | if (errorMessage.includes('Invalid arguments') || errorMessage.includes('Validation')) {
57 | formattedError = `Validation Error: The provided data does not meet requirements.\n\n${errorMessage}`;
58 |
59 | if (context === 'plan') {
60 | formattedError += `\n\nSuggestions for fixing plan validation errors:
61 | - Ensure goal fields are concise (max 60 characters) and focus on WHAT will be done
62 | - Use developer notes for detailed implementation information (max 300 characters)
63 | - Make sure at least one PR plan and one commit plan are provided
64 | - Check that all required fields are present and properly formatted`;
65 | }
66 | }
67 |
68 | return {
69 | content: [{
70 | type: 'text',
71 | text: JSON.stringify({
72 | error: formattedError,
73 | status: 'failed'
74 | }, null, 2)
75 | }],
76 | isError: true
77 | };
78 | }
79 |
80 | // エクスポートする型
81 | export type { PlanTaskInput, UpdateStatusInput, WorkPlanInitOptions };
```
--------------------------------------------------------------------------------
/visualization/src/components/nodes/nodes.css:
--------------------------------------------------------------------------------
```css
1 | /* コミットノードのスタイル */
2 | .commit-node {
3 | border: 2px solid #ccc;
4 | border-radius: 5px;
5 | padding: 10px;
6 | width: 220px;
7 | background-color: white;
8 | transition: all 0.2s ease;
9 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
10 | position: relative;
11 | }
12 |
13 | .commit-node:hover {
14 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
15 | transform: translateY(-2px);
16 | }
17 |
18 | .commit-node .header {
19 | display: flex;
20 | align-items: center;
21 | justify-content: space-between;
22 | margin-bottom: 5px;
23 | }
24 |
25 | .commit-node .title {
26 | margin-top: 5px;
27 | font-weight: bold;
28 | font-size: 14px;
29 | color: #333;
30 | word-break: break-word;
31 | }
32 |
33 | .commit-node .handle {
34 | width: 10px;
35 | height: 10px;
36 | border-radius: 50%;
37 | background-color: #60a5fa;
38 | border: 2px solid white;
39 | transition: transform 0.2s ease, background-color 0.2s ease;
40 | }
41 |
42 | .commit-node .handle-left {
43 | left: -6px;
44 | }
45 |
46 | .commit-node .handle-right {
47 | right: -6px;
48 | }
49 |
50 | /* ホバー時にハンドルを大きく */
51 | .commit-node:hover .handle {
52 | transform: scale(1.2);
53 | background-color: #3b82f6;
54 | }
55 |
56 | .commit-node .status-label {
57 | display: inline-block;
58 | padding: 3px 6px;
59 | border-radius: 12px;
60 | font-size: 11px;
61 | font-weight: 500;
62 | color: white;
63 | }
64 |
65 | .commit-node .status-icon {
66 | margin-right: 4px;
67 | }
68 |
69 | /* ノードアクションボタン */
70 | .node-actions {
71 | position: absolute;
72 | top: -10px;
73 | right: -10px;
74 | display: none;
75 | flex-direction: row;
76 | gap: 4px;
77 | background-color: white;
78 | border-radius: 4px;
79 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
80 | padding: 4px;
81 | z-index: 10;
82 | }
83 |
84 | .commit-node:hover .node-actions {
85 | display: flex;
86 | }
87 |
88 | .node-action-button {
89 | width: 24px;
90 | height: 24px;
91 | display: flex;
92 | align-items: center;
93 | justify-content: center;
94 | border-radius: 4px;
95 | background-color: #f9fafb;
96 | border: 1px solid #e5e7eb;
97 | cursor: pointer;
98 | font-size: 12px;
99 | transition: background-color 0.2s ease;
100 | }
101 |
102 | .node-action-button:hover {
103 | background-color: #f3f4f6;
104 | }
105 |
106 | .node-action-edit {
107 | color: #3b82f6;
108 | }
109 |
110 | .node-action-delete {
111 | color: #ef4444;
112 | }
113 |
114 | /* エッジスタイル */
115 | .react-flow__edge-path {
116 | stroke-width: 2;
117 | stroke: #aaa;
118 | transition: stroke 0.3s, stroke-width 0.3s;
119 | }
120 |
121 | /* エッジアニメーション */
122 | .react-flow__edge-path.animated {
123 | stroke-dasharray: 5;
124 | animation: dashdraw 0.5s linear infinite;
125 | }
126 |
127 | @keyframes dashdraw {
128 | from {
129 | stroke-dashoffset: 10;
130 | }
131 | }
132 |
133 | /* ホバー時のエッジスタイル */
134 | .react-flow__edge:hover .react-flow__edge-path {
135 | stroke-width: 3;
136 | stroke: #3b82f6;
137 | }
138 |
139 | /* エッジラベルスタイル */
140 | .react-flow__edge-text {
141 | font-size: 10px;
142 | fill: #666;
143 | pointer-events: none;
144 | }
145 |
146 | /* PRノードのスタイル */
147 | .pr-node {
148 | padding: 15px;
149 | background-color: white;
150 | border-radius: 8px;
151 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
152 | transition: all 0.3s;
153 | }
154 |
155 | .pr-node:hover {
156 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
157 | transform: translateY(-2px);
158 | }
159 |
160 | /* ミニマップ強調 */
161 | .react-flow__minimap-mask {
162 | fill: rgba(240, 240, 240, 0.6);
163 | }
164 |
165 | .react-flow__minimap-node {
166 | transition: fill 0.2s;
167 | }
168 |
169 | /* ノード選択時のスタイル */
170 | .react-flow__node.selected {
171 | box-shadow: 0 0 0 2px #3b82f6 !important;
172 | }
```
--------------------------------------------------------------------------------
/src/values/pullRequest.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Status } from './status.js';
2 | import { Commit } from './commit.js';
3 |
4 | export type PullRequest = {
5 | goal: string;
6 | status: Status;
7 | commits: Commit[];
8 | needsMoreThoughts?: boolean;
9 | developerNote?: string;
10 | };
11 |
12 | /**
13 | * Generates summary information for a list of pull requests
14 | */
15 | export const generatePRSummaries = (pullRequests: PullRequest[]): Array<{
16 | prIndex: number;
17 | goal: string;
18 | status: Status;
19 | developerNote?: string;
20 | commits: {
21 | completed: number;
22 | total: number;
23 | percentComplete: number;
24 | };
25 | }> => {
26 | return pullRequests.map((pr: PullRequest, prIndex: number) => {
27 | const completedCommits = pr.commits.filter((c: { status: Status }) => c.status === "completed").length;
28 | const totalCommits = pr.commits.length;
29 |
30 | return {
31 | prIndex: prIndex,
32 | goal: pr.goal,
33 | status: pr.status,
34 | developerNote: pr.developerNote,
35 | commits: {
36 | completed: completedCommits,
37 | total: totalCommits,
38 | percentComplete: totalCommits ? Math.round((completedCommits / totalCommits) * 100) : 0
39 | }
40 | };
41 | });
42 | };
43 |
44 | // Helper functions for status updates
45 | export const updatePRStatusBasedOnCommits = (pr: PullRequest): PullRequest => {
46 | const commits = pr.commits;
47 |
48 | if (commits.length === 0) return pr;
49 |
50 | const updatedPr = { ...pr };
51 |
52 | // If all commits are completed, mark PR as completed
53 | if (commits.every(commit => commit.status === "completed")) {
54 | updatedPr.status = "completed";
55 | }
56 | // If any commit is in user_review status, mark PR as user_review (unless there are in_progress commits)
57 | else if (commits.some(commit => commit.status === "user_review") &&
58 | !commits.some(commit => commit.status === "in_progress")) {
59 | updatedPr.status = "user_review";
60 | }
61 | // If any commit is in progress, mark PR as in progress
62 | else if (commits.some(commit => commit.status === "in_progress")) {
63 | updatedPr.status = "in_progress";
64 | }
65 | // "blocked" status has been removed as per requirements
66 |
67 | // If any commit needs refinement, mark PR as needs refinement
68 | else if (commits.some(commit => commit.status === "needsRefinment") &&
69 | !commits.some(commit => commit.status === "in_progress")) {
70 | updatedPr.status = "needsRefinment";
71 | }
72 |
73 | return updatedPr;
74 | };
75 |
76 | /**
77 | * Updates the developer note for a pull request
78 | */
79 | export const updatePRDeveloperNote = (pr: PullRequest, note: string): PullRequest => {
80 | return {
81 | ...pr,
82 | developerNote: note
83 | };
84 | };
85 |
86 | /**
87 | * Updates the developer note for a commit in a pull request
88 | */
89 | export const updateCommitDeveloperNote = (pr: PullRequest, commitIndex: number, note: string): PullRequest => {
90 | if (commitIndex < 0 || commitIndex >= pr.commits.length) {
91 | return pr; // Invalid commit index
92 | }
93 |
94 | const updatedCommits = [...pr.commits];
95 | updatedCommits[commitIndex] = {
96 | ...updatedCommits[commitIndex],
97 | developerNote: note
98 | };
99 |
100 | return {
101 | ...pr,
102 | commits: updatedCommits
103 | };
104 | };
```
--------------------------------------------------------------------------------
/visualization/src/utils/storageUtils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WorkPlan, CommitStatus, PRPlan, CommitPlan } from '../types';
2 |
3 | // Local storage key
4 | const STORAGE_KEY = 'workplan-visualization-data';
5 |
6 | // List of valid status values
7 | const VALID_STATUSES: CommitStatus[] = [
8 | 'not_started',
9 | 'in_progress',
10 | 'completed',
11 | 'cancelled',
12 | 'needsRefinment',
13 | 'user_review'
14 | ];
15 |
16 | /**
17 | * Check if an object is a valid WorkPlan and fix it if necessary
18 | */
19 | const validateWorkPlan = (data: any): WorkPlan => {
20 | // Basic structure check
21 | if (!data || typeof data !== 'object' || !data.goal || !Array.isArray(data.prPlans)) {
22 | throw new Error('Invalid workplan data structure');
23 | }
24 |
25 | // Validate and normalize PRPlans
26 | const validatedPrPlans: PRPlan[] = data.prPlans.map((pr: any): PRPlan => {
27 | if (!pr || typeof pr !== 'object' || !pr.goal || !Array.isArray(pr.commitPlans)) {
28 | throw new Error('Invalid PR data structure');
29 | }
30 |
31 | // Validate status
32 | const status = pr.status && VALID_STATUSES.includes(pr.status as CommitStatus)
33 | ? pr.status as CommitStatus
34 | : undefined;
35 |
36 | // Validate and normalize commit plans
37 | const commitPlans: CommitPlan[] = pr.commitPlans.map((commit: any): CommitPlan => {
38 | if (!commit || typeof commit !== 'object' || !commit.goal) {
39 | throw new Error('Invalid commit data structure');
40 | }
41 |
42 | // Validate commit status
43 | const commitStatus = commit.status && VALID_STATUSES.includes(commit.status as CommitStatus)
44 | ? commit.status as CommitStatus
45 | : undefined;
46 |
47 | return {
48 | goal: commit.goal,
49 | status: commitStatus,
50 | developerNote: commit.developerNote ? String(commit.developerNote) : undefined
51 | };
52 | });
53 |
54 | return {
55 | goal: pr.goal,
56 | status,
57 | commitPlans,
58 | developerNote: pr.developerNote ? String(pr.developerNote) : undefined
59 | };
60 | });
61 |
62 | return {
63 | goal: data.goal,
64 | prPlans: validatedPrPlans
65 | };
66 | };
67 |
68 | /**
69 | * Save workplan to local storage
70 | * @param workplan The workplan to save
71 | */
72 | export const saveWorkPlanToStorage = (workplan: WorkPlan): void => {
73 | try {
74 | const serializedData = JSON.stringify(workplan);
75 | localStorage.setItem(STORAGE_KEY, serializedData);
76 | } catch (error) {
77 | console.error('Failed to save workplan:', error);
78 | }
79 | };
80 |
81 | /**
82 | * Get workplan from local storage
83 | * @returns The stored workplan, or null if not found
84 | */
85 | export const getWorkPlanFromStorage = (): WorkPlan | null => {
86 | try {
87 | const serializedData = localStorage.getItem(STORAGE_KEY);
88 | if (!serializedData) return null;
89 |
90 | const parsedData = JSON.parse(serializedData);
91 | return validateWorkPlan(parsedData);
92 | } catch (error) {
93 | console.error('Failed to get workplan:', error);
94 | return null;
95 | }
96 | };
97 |
98 | /**
99 | * Clear workplan from local storage
100 | */
101 | export const clearWorkPlanStorage = (): void => {
102 | try {
103 | localStorage.removeItem(STORAGE_KEY);
104 | } catch (error) {
105 | console.error('Failed to delete workplan:', error);
106 | }
107 | };
```
--------------------------------------------------------------------------------
/visualization/src/App.css:
--------------------------------------------------------------------------------
```css
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
44 | .app {
45 | display: flex;
46 | flex-direction: column;
47 | height: 100vh;
48 | overflow: hidden;
49 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
50 | }
51 |
52 | header {
53 | flex-shrink: 0;
54 | z-index: 10;
55 | }
56 |
57 | .app-header {
58 | height: 60px;
59 | padding: 0 20px;
60 | display: flex;
61 | align-items: center;
62 | background-color: #1a1a1a;
63 | color: white;
64 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
65 | }
66 |
67 | .app-header h1 {
68 | font-size: 1.5rem;
69 | margin: 0;
70 | }
71 |
72 | .app-main {
73 | flex-grow: 1;
74 | overflow: hidden;
75 | position: relative;
76 | }
77 |
78 | /* フィルターパネルのスタイル */
79 | .filter-panel-container {
80 | position: absolute;
81 | top: 80px;
82 | left: 20px;
83 | z-index: 20;
84 | max-width: 90%;
85 | width: 320px;
86 | }
87 |
88 | /* ノードとエッジのスタイル */
89 | .pr-node {
90 | border-radius: 8px;
91 | background-color: #f3f4f6;
92 | border: 2px solid #9ca3af;
93 | padding: 10px;
94 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
95 | }
96 |
97 | .commit-node {
98 | min-height: 80px;
99 | width: 100%;
100 | word-break: break-word;
101 | }
102 |
103 | /* ReactFlowのカスタマイズ */
104 | .react-flow__node {
105 | transition: all 0.2s ease-in-out;
106 | }
107 |
108 | .react-flow__node:hover {
109 | transform: translateY(-2px);
110 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
111 | }
112 |
113 | .react-flow__node.selected {
114 | box-shadow: 0 0 0 2px #3b82f6;
115 | }
116 |
117 | .react-flow__edge-path.animated {
118 | stroke-dasharray: 5;
119 | animation: dashdraw 3s linear infinite;
120 | }
121 |
122 | @keyframes dashdraw {
123 | from {
124 | stroke-dashoffset: 10;
125 | }
126 | }
127 |
128 | /* レスポンシブデザイン */
129 | @media (max-width: 768px) {
130 | header {
131 | flex-direction: column;
132 | gap: 10px;
133 | align-items: flex-start;
134 | padding: 10px;
135 | }
136 |
137 | header h1 {
138 | font-size: 1.5rem;
139 | }
140 |
141 | header .flex {
142 | flex-wrap: wrap;
143 | gap: 8px;
144 | }
145 |
146 | .filter-panel-container {
147 | top: 120px;
148 | left: 10px;
149 | width: calc(100% - 20px);
150 | max-width: none;
151 | }
152 | }
153 |
154 | /* アニメーション */
155 | @keyframes fadeIn {
156 | from {
157 | opacity: 0;
158 | transform: translateY(10px);
159 | }
160 | to {
161 | opacity: 1;
162 | transform: translateY(0);
163 | }
164 | }
165 |
166 | .filter-panel-container > div,
167 | .export-panel-container > div {
168 | animation: fadeIn 0.3s ease-out;
169 | }
170 |
171 | /* ダークモードサポート */
172 | @media (prefers-color-scheme: dark) {
173 | .react-flow__node {
174 | color: #f1f5f9;
175 | }
176 |
177 | .react-flow__node.start-node {
178 | background-color: #1e3a8a;
179 | border-color: #3b82f6;
180 | }
181 |
182 | .react-flow__node.pr-node {
183 | background-color: #1f2937;
184 | border-color: #4b5563;
185 | }
186 |
187 | .commit-node {
188 | background-color: #374151;
189 | border-color: #6b7280;
190 | }
191 | }
192 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5 | import path from 'path';
6 | import { fileURLToPath } from 'url';
7 |
8 | import { PLAN_TOOL, TRACK_TOOL, UPDATE_STATUS_TOOL } from "./tools/index.js";
9 | import { taskPlanningGuide } from "./prompts.js";
10 | import logger, { LogLevel } from './utils/logger.js';
11 | import { WorkPlan, WorkPlanInitOptions } from './aggregates/workplan.js';
12 |
13 | const __filename = fileURLToPath(import.meta.url);
14 | const __dirname = path.dirname(__filename);
15 |
16 | const logLevel = LogLevel.INFO;
17 | logger.setLogLevel(logLevel);
18 | logger.info(`Logger initialized with level: ${LogLevel[logLevel]}`);
19 |
20 | const dataDir = path.resolve(__dirname, '../visualization/public/data');
21 | const dataFileName = 'workplan.json';
22 |
23 | const workPlanOptions: WorkPlanInitOptions = {
24 | dataDir,
25 | dataFileName
26 | };
27 |
28 | export const workPlan = new WorkPlan();
29 | const initSuccess = workPlan.initialize(workPlanOptions);
30 |
31 | if (!initSuccess) {
32 | logger.error(`Failed to initialize WorkPlan with options: ${JSON.stringify(workPlanOptions)}`);
33 | process.exit(1);
34 | }
35 |
36 | const server = new McpServer({
37 | name: "micromanage",
38 | version: "0.1.0",
39 | description: `
40 | Externalizing developers' working memory and procedures related to work plans for resolving tickets.
41 | Because it would involve unnecessary overhead, we **do not apply it to tiny tasks**.
42 |
43 | This server helps organize and track development tasks through a structured approach with the following key features:
44 | - Breaking down the tasks of the current ticket into minimal PRs and commits
45 | - Tracking progress of implementation work
46 | - Updating status of development items as work progresses
47 |
48 | ## When to Use This
49 | - When users request ticket assignment
50 | - When users ask for detailed task management
51 | - For complex tasks requiring structured breakdown and tracking
52 |
53 | Best practices:
54 | 1. Start with the task-planning-guide prompt before creating a plan
55 | 2. Keep PRs minimal and focused on single logical changes
56 | 3. Make commits small and atomic
57 | 4. Update status promptly as work begins or completes
58 | 5. Check progress regularly to maintain workflow efficiency
59 | 6. Always check the agentInstruction field in the response JSON from the 'track' tool
60 | `,
61 | capabilities: {
62 | tools: {
63 | listChanged: true
64 | },
65 | prompts: {
66 | listChanged: true
67 | }
68 | }
69 | });
70 |
71 | server.prompt(
72 | taskPlanningGuide.name,
73 | taskPlanningGuide.description,
74 | {},
75 | taskPlanningGuide.handler
76 | );
77 |
78 | [PLAN_TOOL, TRACK_TOOL, UPDATE_STATUS_TOOL].forEach(tool => {
79 | server.tool(tool.name, tool.description ?? "", tool.schema, tool.handler);
80 | });
81 |
82 | async function runServer() {
83 | const transport = new StdioServerTransport();
84 | await server.connect(transport);
85 | }
86 |
87 | process.on('SIGINT', () => {
88 | workPlan.forceSave();
89 | process.exit(0);
90 | });
91 |
92 | process.on('SIGTERM', () => {
93 | workPlan.forceSave();
94 | process.exit(0);
95 | });
96 |
97 | runServer().catch((error) => {
98 | logger.logError("Fatal error running server", error);
99 | workPlan.forceSave();
100 | process.exit(1);
101 | });
```
--------------------------------------------------------------------------------
/visualization/src/utils/responsiveUtils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { useState, useEffect } from 'react';
2 |
3 | // Breakpoint definitions
4 | export enum Breakpoint {
5 | xs = 0, // Extra small devices (phones)
6 | sm = 640, // Small devices (tablets)
7 | md = 768, // Medium devices (landscape tablets)
8 | lg = 1024, // Large devices (desktops)
9 | xl = 1280, // Extra large devices (large desktops)
10 | xxl = 1536 // Double extra large devices
11 | }
12 |
13 | // Custom hook to determine current breakpoint
14 | export function useBreakpoint() {
15 | const [breakpoint, setBreakpoint] = useState<keyof typeof Breakpoint>(() => {
16 | // Set initial value
17 | return getBreakpoint(window.innerWidth);
18 | });
19 |
20 | useEffect(() => {
21 | // Handler for window resize events
22 | const handleResize = () => {
23 | setBreakpoint(getBreakpoint(window.innerWidth));
24 | };
25 |
26 | // Register listener
27 | window.addEventListener('resize', handleResize);
28 |
29 | // Cleanup function
30 | return () => {
31 | window.removeEventListener('resize', handleResize);
32 | };
33 | }, []);
34 |
35 | return breakpoint;
36 | }
37 |
38 | // Function to determine breakpoint from width
39 | function getBreakpoint(width: number): keyof typeof Breakpoint {
40 | if (width >= Breakpoint.xxl) return 'xxl';
41 | if (width >= Breakpoint.xl) return 'xl';
42 | if (width >= Breakpoint.lg) return 'lg';
43 | if (width >= Breakpoint.md) return 'md';
44 | if (width >= Breakpoint.sm) return 'sm';
45 | return 'xs';
46 | }
47 |
48 | // Utility function to return responsive values
49 | type ResponsiveValues<T> = {
50 | [key in keyof typeof Breakpoint]?: T;
51 | } & {
52 | default: T;
53 | };
54 |
55 | export function getResponsiveValue<T>(
56 | values: ResponsiveValues<T>,
57 | currentBreakpoint: keyof typeof Breakpoint
58 | ): T {
59 | // Return the value of the largest breakpoint less than or equal to the current breakpoint
60 | const breakpoints: (keyof typeof Breakpoint)[] = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'];
61 | const currentIndex = breakpoints.indexOf(currentBreakpoint);
62 |
63 | // Search in descending order from current breakpoint
64 | for (let i = currentIndex; i >= 0; i--) {
65 | const bp = breakpoints[i];
66 | if (values[bp] !== undefined) {
67 | return values[bp] as T;
68 | }
69 | }
70 |
71 | // Return default value if not found
72 | return values.default;
73 | }
74 |
75 | // Hook for adjusting react-flow canvas size
76 | export function useResponsiveFlowDimensions() {
77 | const breakpoint = useBreakpoint();
78 |
79 | // Adjust flow canvas size based on breakpoint
80 | const nodePadding = getResponsiveValue<number>(
81 | {
82 | xs: 5,
83 | sm: 10,
84 | md: 15,
85 | lg: 20,
86 | default: 20
87 | },
88 | breakpoint
89 | );
90 |
91 | const nodeSpacing = getResponsiveValue<number>(
92 | {
93 | xs: 30,
94 | sm: 40,
95 | md: 50,
96 | lg: 60,
97 | default: 60
98 | },
99 | breakpoint
100 | );
101 |
102 | const miniMapVisible = getResponsiveValue<boolean>(
103 | {
104 | xs: false,
105 | sm: false,
106 | md: true,
107 | default: true
108 | },
109 | breakpoint
110 | );
111 |
112 | const controlsStyle = getResponsiveValue<React.CSSProperties>(
113 | {
114 | xs: { right: '5px', bottom: '5px', transform: 'scale(0.8)' },
115 | sm: { right: '10px', bottom: '10px', transform: 'scale(0.9)' },
116 | md: { right: '10px', bottom: '10px' },
117 | default: { right: '10px', bottom: '10px' }
118 | },
119 | breakpoint
120 | );
121 |
122 | const miniMapStyle = getResponsiveValue<React.CSSProperties>(
123 | {
124 | xs: { display: 'none' },
125 | sm: { display: 'none' },
126 | md: { right: 10, top: 10, width: 120, height: 80 },
127 | lg: { right: 10, top: 10, width: 150, height: 100 },
128 | default: { right: 10, top: 10, width: 200, height: 120 }
129 | },
130 | breakpoint
131 | );
132 |
133 | return {
134 | nodePadding,
135 | nodeSpacing,
136 | miniMapVisible,
137 | controlsStyle,
138 | miniMapStyle,
139 | currentBreakpoint: breakpoint
140 | };
141 | }
```
--------------------------------------------------------------------------------
/src/tools/update/toolDefs.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 | import { Tool, createErrorResponse, UpdateStatusInput } from '../common.js';
3 | import { workPlan } from '../../index.js';
4 | import logger from '../../utils/logger.js';
5 | import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
6 |
7 | export const UPDATE_STATUS_TOOL: Tool<{
8 | prIndex: z.ZodNumber;
9 | commitIndex: z.ZodNumber;
10 | status: z.ZodEnum<["not_started", "in_progress", "user_review", "completed", "cancelled", "needsRefinment"]>;
11 | goal?: z.ZodOptional<z.ZodString>;
12 | developerNote?: z.ZodOptional<z.ZodString>;
13 | }> = {
14 | name: "update",
15 | description: `
16 | A tool for updating the status and goals of development tasks.
17 | **IMPORTANT**:
18 | - There is always exactly one task in either the in_progress or user_review or needsRefinment state.
19 | - When setting status to "user_review", you MUST generate a review request message to the user.
20 | - Always check and follow **task-status-update-rule.mdc** when updating task status.
21 |
22 | **After receiving the tool response, you MUST**::
23 | - 1. track the current whole status of the workplan.
24 | - 2. check the detailed information including developer notes in the next task.
25 | `,
26 | schema: {
27 | prIndex: z.number().int().min(0, "PR index must be a non-negative integer").describe("Index of the PR containing the commit to update. Zero-based index in the PR array."),
28 | commitIndex: z.number().int().describe("Index of the commit to update within the specified PR. Zero-based index in the commit array. Use -1 to update the PR directly."),
29 | status: z.enum(["not_started", "in_progress", "user_review", "completed", "cancelled", "needsRefinment"])
30 | .describe("New status value for the commit. Valid values: 'not_started' (work has not begun), 'in_progress' (currently being worked on), 'user_review' (waiting for user to review), 'completed' (work is finished), 'cancelled' (work has been abandoned), or 'needsRefinment' (needs further refinement)."),
31 | goal: z.string().min(1, "Goal must be a non-empty string").optional()
32 | .describe("New goal description for the commit. Optional - only provide if you want to change the commit goal."),
33 | developerNote: z.string().optional()
34 | .describe("Developer implementation notes. Can be added to both PRs and commits to document important implementation details.")
35 | },
36 | handler: async (params, extra: RequestHandlerExtra) => {
37 | try {
38 | logger.info(`Update tool called for PR #${params.prIndex}, commit #${params.commitIndex}, status: ${params.status}`);
39 |
40 | if (!workPlan) {
41 | logger.error('WorkPlan instance is not available');
42 | return createErrorResponse('WorkPlan instance is not initialized');
43 | }
44 |
45 | if (!workPlan.isInitialized()) {
46 | logger.error('WorkPlan is not initialized');
47 | return createErrorResponse('WorkPlan is not ready. Server initialization incomplete.');
48 | }
49 |
50 | const updateParams: UpdateStatusInput = {
51 | prIndex: params.prIndex,
52 | commitIndex: params.commitIndex,
53 | status: params.status,
54 | goal: params.goal ? String(params.goal) : undefined,
55 | developerNote: params.developerNote ? String(params.developerNote) : undefined
56 | };
57 |
58 | const result = workPlan.updateStatus(updateParams);
59 |
60 | return {
61 | content: result.content.map(item => ({
62 | type: "text" as const,
63 | text: item.text
64 | })),
65 | isError: result.isError
66 | };
67 | } catch (error) {
68 | logger.logError('Error in UPDATE_STATUS tool', error);
69 | return createErrorResponse(error);
70 | }
71 | }
72 | };
```
--------------------------------------------------------------------------------
/visualization/src/components/CommitNode.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { useRef, useState } from 'react';
2 | import { Handle, Position } from 'reactflow';
3 | import { CommitStatus } from '../types';
4 |
5 | export interface CommitNodeData {
6 | title: string;
7 | label?: string;
8 | status: CommitStatus;
9 | prIndex: number;
10 | commitIndex: number;
11 | developerNote?: string; // Developer implementation notes captured during refinement
12 | }
13 |
14 | const statusLabels: Record<CommitStatus, string> = {
15 | 'not_started': 'Not Started',
16 | 'in_progress': 'In Progress',
17 | 'completed': 'Completed',
18 | 'cancelled': 'Cancelled',
19 | 'needsRefinment': 'Needs Refinement',
20 | 'user_review': 'Awaiting User Review'
21 | };
22 |
23 | const statusIcons: Record<CommitStatus, string> = {
24 | 'not_started': '⚪',
25 | 'in_progress': '🔵',
26 | 'completed': '✅',
27 | 'cancelled': '❌',
28 | 'needsRefinment': '🔄',
29 | 'user_review': '👀'
30 | };
31 |
32 | const statusColors: Record<CommitStatus, string> = {
33 | 'not_started': 'bg-gray-100 text-gray-600',
34 | 'in_progress': 'bg-blue-100 text-blue-600',
35 | 'completed': 'bg-green-100 text-green-600',
36 | 'cancelled': 'bg-red-100 text-red-600',
37 | 'needsRefinment': 'bg-purple-100 text-purple-600',
38 | 'user_review': 'bg-violet-100 text-violet-600'
39 | };
40 |
41 | function CommitNode({ data }: { data: CommitNodeData }) {
42 | const nodeRef = useRef<HTMLDivElement>(null);
43 | const [showTooltip, setShowTooltip] = useState(false);
44 |
45 | const getStatusClass = (status: CommitStatus): string => {
46 | const baseClass = `status-badge ${status}`;
47 |
48 | // Additional style for completed commits
49 | if (status === 'completed') {
50 | return `${baseClass} completed-commit`;
51 | }
52 |
53 | return baseClass;
54 | };
55 |
56 | return (
57 | <div
58 | ref={nodeRef}
59 | className={`commit-node transition-all relative py-3 px-4 rounded-md border shadow-sm ${
60 | data.status === 'completed' ? 'completed-node' : ''
61 | }`}
62 | aria-label={`Commit: ${data.title}, Status: ${statusLabels[data.status]}`}
63 | onMouseEnter={() => data.developerNote && setShowTooltip(true)}
64 | onMouseLeave={() => setShowTooltip(false)}
65 | >
66 | <Handle
67 | type="target"
68 | position={Position.Left}
69 | id="left"
70 | className="w-3 h-3 !bg-blue-500"
71 | aria-label="Left input point"
72 | />
73 |
74 | <Handle
75 | type="target"
76 | position={Position.Left}
77 | id="top"
78 | className="w-3 h-3 !bg-blue-500"
79 | aria-label="Top input point"
80 | />
81 |
82 | {/* Status Badge */}
83 | <div
84 | className={`${getStatusClass(data.status)} inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mx-auto`}
85 | aria-label={`Status: ${statusLabels[data.status]}`}
86 | >
87 | <span className="mr-1" aria-hidden="true">{statusIcons[data.status]}</span>
88 | <span>{statusLabels[data.status]}</span>
89 | </div>
90 | {/* Goal (Title) */}
91 | <div
92 | className="text-base font-medium break-words text-center mb-2"
93 | aria-label="Commit goal"
94 | >
95 | {data.title}
96 | </div>
97 |
98 | {/* Developer Note Tooltip */}
99 | {showTooltip && data.developerNote && (
100 | <div className="absolute z-10 bg-white dark:bg-gray-800 p-3 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 max-w-xs -translate-x-1/2 left-1/2 bottom-full mb-2 text-sm">
101 | <div className="font-semibold mb-1 text-gray-700 dark:text-gray-300">Developer Note:</div>
102 | <div className="text-gray-600 dark:text-gray-400">{data.developerNote}</div>
103 | <div className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-0 h-0 border-l-8 border-l-transparent border-r-8 border-r-transparent border-t-8 border-t-gray-200 dark:border-t-gray-700"></div>
104 | </div>
105 | )}
106 |
107 | <Handle
108 | type="source"
109 | position={Position.Right}
110 | id="right"
111 | className="w-3 h-3 !bg-blue-500"
112 | aria-label="Right output point"
113 | />
114 |
115 | <Handle
116 | type="source"
117 | position={Position.Bottom}
118 | id="bottom"
119 | className="w-3 h-3 !bg-blue-500"
120 | aria-label="Bottom output point"
121 | />
122 | </div>
123 | );
124 | }
125 |
126 | export default CommitNode;
```
--------------------------------------------------------------------------------
/visualization/src/assets/react.svg:
--------------------------------------------------------------------------------
```
1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
```
--------------------------------------------------------------------------------
/visualization/src/utils/exportUtils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WorkPlan, CommitStatus } from '../types';
2 |
3 | // Status display name mapping
4 | const STATUS_LABELS: Record<CommitStatus, string> = {
5 | 'not_started': 'Not Started',
6 | 'in_progress': 'In Progress',
7 | 'completed': 'Completed',
8 | 'cancelled': 'Cancelled',
9 | 'needsRefinment': 'Needs Refinement',
10 | 'user_review': 'Waiting for User Review'
11 | };
12 |
13 | // Status icon mapping
14 | const STATUS_ICONS: Record<CommitStatus, string> = {
15 | 'not_started': '⚪',
16 | 'in_progress': '🔄',
17 | 'completed': '✅',
18 | 'cancelled': '❌',
19 | 'needsRefinment': '⚠️',
20 | 'user_review': '👀'
21 | };
22 |
23 | /**
24 | * Export WorkPlan as JSON
25 | * @param workplan The workplan to export
26 | * @returns Formatted JSON string
27 | */
28 | export const exportToJSON = (workplan: WorkPlan): string => {
29 | return JSON.stringify(workplan, null, 2);
30 | };
31 |
32 | /**
33 | * Export WorkPlan as Markdown
34 | * @param workplan The workplan to export
35 | * @returns Markdown formatted string
36 | */
37 | export const exportToMarkdown = (workplan: WorkPlan): string => {
38 | let markdown = `# ${workplan.goal}\n\n`;
39 |
40 | markdown += `Created: ${new Date().toLocaleString()}\n\n`;
41 |
42 | const totalPRs = workplan.prPlans.length;
43 | const completedPRs = workplan.prPlans.filter(pr =>
44 | pr.status === 'completed' ||
45 | pr.commitPlans.every(commit => commit.status === 'completed')
46 | ).length;
47 |
48 | const totalCommits = workplan.prPlans.reduce((total, pr) => total + pr.commitPlans.length, 0);
49 | const completedCommits = workplan.prPlans.reduce((total, pr) =>
50 | total + pr.commitPlans.filter(commit => commit.status === 'completed').length,
51 | 0);
52 |
53 | markdown += `## Progress Summary\n\n`;
54 | markdown += `- PR Progress: ${completedPRs}/${totalPRs} (${Math.round(completedPRs / totalPRs * 100)}%)\n`;
55 | markdown += `- Commit Progress: ${completedCommits}/${totalCommits} (${Math.round(completedCommits / totalCommits * 100)}%)\n\n`;
56 |
57 | workplan.prPlans.forEach((pr, prIndex) => {
58 | const prStatus = pr.status ||
59 | (pr.commitPlans.every(commit => commit.status === 'completed') ? 'completed' :
60 | pr.commitPlans.some(commit => commit.status === 'in_progress') ? 'in_progress' : 'not_started');
61 |
62 | const prIcon = STATUS_ICONS[prStatus as CommitStatus] || '';
63 |
64 | markdown += `## PR ${prIndex + 1}: ${pr.goal} ${prIcon}\n\n`;
65 |
66 | pr.commitPlans.forEach((commit, commitIndex) => {
67 | const status = commit.status || 'not_started';
68 | const statusIcon = STATUS_ICONS[status];
69 | const statusLabel = STATUS_LABELS[status];
70 |
71 | markdown += `### Commit ${commitIndex + 1}: ${statusIcon} ${commit.goal}\n`;
72 | markdown += `Status: ${statusLabel}\n\n`;
73 | });
74 |
75 | markdown += '\n';
76 | });
77 |
78 | return markdown;
79 | };
80 |
81 | /**
82 | * Export WorkPlan as CSV
83 | * @param workplan The workplan to export
84 | * @returns CSV formatted string
85 | */
86 | export const exportToCSV = (workplan: WorkPlan): string => {
87 | let csv = 'PR Number,PR Goal,PR Status,Commit Number,Commit Goal,Commit Status\n';
88 |
89 | workplan.prPlans.forEach((pr, prIndex) => {
90 | const prStatus = pr.status ||
91 | (pr.commitPlans.every(commit => commit.status === 'completed') ? 'completed' :
92 | pr.commitPlans.some(commit => commit.status === 'in_progress') ? 'in_progress' : 'not_started');
93 |
94 | pr.commitPlans.forEach((commit, commitIndex) => {
95 | const status = commit.status || 'not_started';
96 | const statusLabel = STATUS_LABELS[status];
97 |
98 | const escapeCsv = (text: string) => `"${text.replace(/"/g, '""')}"`;
99 |
100 | csv += `${prIndex + 1},${escapeCsv(pr.goal)},${STATUS_LABELS[prStatus as CommitStatus] || ''},`;
101 | csv += `${commitIndex + 1},${escapeCsv(commit.goal)},${statusLabel}\n`;
102 | });
103 | });
104 |
105 | return csv;
106 | };
107 |
108 | /**
109 | * Download file to the user's device
110 | * @param content File content
111 | * @param fileName File name
112 | * @param contentType Content type
113 | */
114 | export const downloadFile = (content: string, fileName: string, contentType: string): void => {
115 | const a = document.createElement('a');
116 | const file = new Blob([content], { type: contentType });
117 |
118 | a.href = URL.createObjectURL(file);
119 | a.download = fileName;
120 | a.click();
121 |
122 | URL.revokeObjectURL(a.href);
123 | };
124 |
125 | /**
126 | * Download WorkPlan as JSON
127 | * @param workplan The workplan to export
128 | * @param fileName File name (default: workplan.json)
129 | */
130 | export const downloadAsJSON = (workplan: WorkPlan, fileName: string = 'workplan.json'): void => {
131 | const json = exportToJSON(workplan);
132 | downloadFile(json, fileName, 'application/json');
133 | };
134 |
135 | /**
136 | * Download WorkPlan as Markdown
137 | * @param workplan The workplan to export
138 | * @param fileName File name (default: workplan.md)
139 | */
140 | export const downloadAsMarkdown = (workplan: WorkPlan, fileName: string = 'workplan.md'): void => {
141 | const markdown = exportToMarkdown(workplan);
142 | downloadFile(markdown, fileName, 'text/markdown');
143 | };
144 |
145 | /**
146 | * Download WorkPlan as CSV
147 | * @param workplan The workplan to export
148 | * @param fileName File name (default: workplan.csv)
149 | */
150 | export const downloadAsCSV = (workplan: WorkPlan, fileName: string = 'workplan.csv'): void => {
151 | const csv = exportToCSV(workplan);
152 | downloadFile(csv, fileName, 'text/csv');
153 | };
```
--------------------------------------------------------------------------------
/src/prompts.ts:
--------------------------------------------------------------------------------
```typescript
1 | interface RequestExtra {
2 | readonly context?: {
3 | [key: string]: unknown;
4 | };
5 | readonly [key: string]: unknown;
6 | }
7 |
8 | // hope that the cursor supports the prompt as an mcp client
9 | // alternative, you can include this in your .mdc
10 | export const taskPlanningGuide = {
11 | name: "task-planning-guide",
12 | description: "A comprehensive guide for planning development tasks with minimal PRs and commits. Helps structure work before using the plan tool.",
13 | handler: () => ({
14 | description: "Task planning guide for minimal PRs and commits",
15 | messages: [
16 | {
17 | role: "user" as const,
18 | content: {
19 | type: "text" as const,
20 | text: `
21 | # Task Planning Guide
22 |
23 | ## Purpose
24 | - To design a structured implementation approach before any coding begins
25 | - To ensure clear understanding of requirements and dependencies
26 | - To divide tickets into small PRs and commits for easier review and integration
27 |
28 | ## Output
29 | - A comprehensive work plan organized by PRs and commits
30 | - Each PR should be small, functional, and independently reviewable
31 | - Each commit should represent an atomic change within its PR
32 | - To minimize dependencies between PRs, ideally making them independently implementable
33 |
34 | ## Method
35 | ### Analysis Phase
36 | - Analyze the task's architecture requirements thoroughly
37 | - Explore and read all potentially relevant existing code and similar reference code
38 | - Develop a meta architecture plan for the solution
39 |
40 | ### Planning Approach
41 | - Break down the task into smallest possible PRs
42 | - Order PRs logically (e.g., setup → core logic → tests)
43 | - Define 2-4 minimal atomic commits for each PR
44 | - Prefer splitting tasks into PRs rather than large commits
45 |
46 | ### Implementation Criteria
47 | - Only proceed after achieving 100% clarity and confidence
48 | - Ensure all affected parts of the codebase have been fully identified through dependency tracing
49 | - **The work plan must be approved by the user before implementation**
50 |
51 | ## Prohibited Actions
52 | - **Any implementation or code writing, even "example code"**
53 | `
54 | }
55 | }
56 | ]
57 | })
58 | };
59 |
60 |
61 | // internal prompt
62 | export const progressInstructionGuide = {
63 | name: "progressInstructionGuide",
64 | description: "Based on this progress report, analyze the current state and suggest the next commit to work on.",
65 | text: `
66 | Based on this progress report, analyze the current state and suggest the next commit to work on.
67 | **Next, you must strictly follow these procedures without exception**:
68 | 1. Before starting your next commit, obtain the user's approval.
69 | 2. Once approved, implement the code and test scripts strictly within this commit's scope.
70 | - Make sure there are no build errors or test failures.
71 | - If a UI is involved, verify its behavior using mcp playwright.
72 | 3. After completing the commit:
73 | - Review and evaluate the implementation, and conduct a self-feedback cycle.
74 | - Request feedback from the user.
75 |
76 | **User Review State Procedures**:
77 | When setting a task status to "user_review":
78 | 1. Create a comprehensive review request message that includes:
79 | - A clear summary of the implementation derived from PR/commit goals and developer notes
80 | - Specific changes made and their intended functionality
81 | - Areas that particularly need user verification
82 | - Clear instructions for the user to approve (change status to "completed") or request changes
83 | 2. Format the review request message in a structured way with clear section headings
84 | 3. Remember that tasks cannot transition directly from other states to "completed" - they must go through "user_review" first
85 | 4. Only users can transition tasks from "user_review" to "completed" after their review
86 |
87 | **Always secure the user's agreement before starting the next task.**
88 | `};
89 |
90 | // alternative, you can include this in your .mdc
91 | export const updateTaskStatusRule= {
92 | name: "update-task-status-rule",
93 | description: "Always When updating the status of a task",
94 | text: `
95 | **STRICT RULES - MUST BE FOLLOWED WITHOUT EXCEPTION:**
96 |
97 | in_progress → user_review conditions:
98 | ✅ No compilation errors exist
99 | ✅ Necessary tests have been added and all pass
100 | ✅ Required documentation updates are completed
101 |
102 | needsRefinment → in_progress conditions:
103 | ✅ Requirements have been sufficiently clarified and ready for implementation
104 | ✅ All necessary information for implementation is available
105 | ✅ The scope of the task is clearly defined
106 |
107 | user_review → in_progress conditions:
108 | ✅ From feedback, the content to be modified is completely clear
109 |
110 | * → needsRefinment conditions:
111 | ✅ It becomes apparent that the task requirements are unclear or incomplete
112 |
113 | * → cancelled conditions:
114 | ✅ There is a clear reason why the task is no longer needed, or alternative methods or solutions to meet the requirements are clear
115 | ✅ The impact of cancellation on other related tasks has been evaluated
116 | `};
117 |
118 | // alternative, you can include this in your .mdc
119 | export const solutionExplorationGuide= {
120 | name: "solution-exploration-guide",
121 | description: "When examining how to implement an issue",
122 | text: `
123 | ## Purpose
124 | - nformation gathering and Brainstorming potential approaches
125 |
126 | ## Forbidden:
127 | - Concrete planning, implementation details, or any code writing
128 |
129 | ##output-format
130 | - file: ticket_[issue name].md
131 | - chapters:
132 | - Summary of the issue that will be resolved with the ticket
133 | - Overview of the architecture, domain model and data model to be changed or added this time
134 | - Work plan based on PR and commitment units(This is only for creating chapters, do not write content)
135 |
136 | ## Method
137 | - Understand the issue from the given information and existing code
138 | - research and interviews with users to ensure that we have all the information we need to complete the task
139 | - Analyze potential impacts to the existing codebase
140 | `};
```
--------------------------------------------------------------------------------
/src/tools/plan/toolDefs.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 | import { Tool, createErrorResponse, PlanTaskInput } from '../common.js';
3 | import { workPlan } from '../../index.js';
4 | import logger from '../../utils/logger.js';
5 | import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
6 |
7 | // Improved error messages with guidance
8 | const goalLengthErrorMessage = "Goal must be at most 60 characters. Consider moving detailed information to the developerNote field.";
9 | const developerNoteLengthErrorMessage = "Developer note must be at most 300 characters. Consider breaking it into smaller, focused notes.";
10 |
11 | export const PLAN_TOOL: Tool<{
12 | goal: z.ZodString;
13 | prPlans: z.ZodArray<z.ZodObject<{
14 | goal: z.ZodString;
15 | commitPlans: z.ZodArray<z.ZodObject<{
16 | goal: z.ZodString;
17 | developerNote?: z.ZodOptional<z.ZodString>;
18 | }>>;
19 | developerNote?: z.ZodOptional<z.ZodString>;
20 | }>>;
21 | needsMoreThoughts: z.ZodOptional<z.ZodBoolean>;
22 | }> = {
23 | name: "plan",
24 | description: `
25 | A tool for managing a development work plan for a ticket, organized by PRs and commits.
26 | Register or update the whole work plan for the current ticket you assigned to.
27 |
28 | Before using this tool, you **MUST**:
29 | - Understand requirements, goals, and specifications.
30 | - Clarify the task scope and break it down into a hierarchy of PRs and commit plans.
31 | - Analyze existing codebase and impact area.
32 | - Include developer notes to document implementation considerations discovered during refinement.
33 |
34 | Make sure PR and commit goals are clear enough to stand alone without their developer notes
35 | `,
36 | schema: {
37 | goal: z.string().min(1, "Goal must be a non-empty string").max(60, goalLengthErrorMessage)
38 | .describe("High-level description of what this ticket aims to achieve. Should be concise and focus on the overall outcome or deliverable of the entire task."),
39 | prPlans: z.array(z.object({
40 | goal: z.string().min(1, "PR goal must be a non-empty string").max(60, goalLengthErrorMessage)
41 | .describe("Description of what this PR aims to accomplish. Should be written as a PR title would be - concise and focused on WHAT will be delivered."),
42 | commitPlans: z.array(z.object({
43 | goal: z.string().min(1, "Commit goal must be a non-empty string").max(60, goalLengthErrorMessage)
44 | .describe("Description of what this commit aims to accomplish. Should match the format of an actual commit message - concise and specific to a single change."),
45 | developerNote: z.string().max(300, developerNoteLengthErrorMessage).optional()
46 | .describe("Developer implementation notes for this commit. Use this field for detailed HOW information and implementation considerations discovered during refinement.")
47 | })).min(1, "At least one commit plan is required").describe("Array of commit plans for this PR. Each commit should be small and atomic."),
48 | developerNote: z.string().max(300, developerNoteLengthErrorMessage).optional()
49 | .describe("Developer implementation notes for this PR. Use this field for detailed implementation considerations and technical context discovered during refinement.")
50 | })).min(1, "At least one PR plan is required").describe("Array of PR plans. Each PR represents a logical unit of work in the implementation plan."),
51 | needsMoreThoughts: z.boolean().optional().describe("Whether this plan might need further refinement. Set to true if you think the plan may need changes.")
52 | },
53 | handler: async (params, extra: RequestHandlerExtra) => {
54 | try {
55 | logger.info(`Plan tool called with goal: ${params.goal}, PRs: ${params.prPlans.length}`);
56 |
57 | if (!workPlan) {
58 | logger.error('WorkPlan instance is not available');
59 | return createErrorResponse('WorkPlan instance is not initialized', 'plan');
60 | }
61 |
62 | if (!workPlan.isInitialized()) {
63 | logger.error('WorkPlan is not initialized');
64 | return createErrorResponse('WorkPlan is not ready. Server initialization incomplete.', 'plan');
65 | }
66 |
67 | const planParams: PlanTaskInput = {
68 | goal: params.goal,
69 | prPlans: params.prPlans.map(pr => ({
70 | goal: pr.goal,
71 | commitPlans: pr.commitPlans.map(commit => ({
72 | goal: commit.goal,
73 | developerNote: commit.developerNote ? String(commit.developerNote) : undefined
74 | })),
75 | developerNote: pr.developerNote ? String(pr.developerNote) : undefined
76 | })),
77 | needsMoreThoughts: params.needsMoreThoughts
78 | };
79 |
80 | const result = workPlan.plan(planParams);
81 |
82 | return {
83 | content: result.content.map(item => ({
84 | type: "text" as const,
85 | text: item.text
86 | })),
87 | isError: result.isError
88 | };
89 | } catch (error) {
90 | // Enhance error handling with guidance
91 | const errorMessage = error instanceof Error ? error.message : String(error);
92 |
93 | // Detect specific validation errors and enhance with guidance
94 | let enhancedError = errorMessage;
95 | if (errorMessage.includes("too_big") && errorMessage.includes("goal")) {
96 | enhancedError = `Validation Error: Goal field exceeds maximum length of 60 characters.
97 | Please move implementation details to the developerNote field and keep goal fields concise.
98 | - Goal fields should describe WHAT will be done (the end result)
99 | - Developer notes should explain HOW it will be done (implementation details)`;
100 | } else if (errorMessage.includes("too_big") && errorMessage.includes("developerNote")) {
101 | enhancedError = `Validation Error: Developer note exceeds maximum length of 300 characters.
102 | Please break down the information into smaller, more focused notes or consider creating separate commits for different aspects.`;
103 | }
104 |
105 | logger.logError(`Error in PLAN tool`, error);
106 | return createErrorResponse(enhancedError, 'plan');
107 | }
108 | }
109 | };
```
--------------------------------------------------------------------------------
/visualization/src/index.css:
--------------------------------------------------------------------------------
```css
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
7 | line-height: 1.5;
8 | font-weight: 400;
9 |
10 | /* ライトモード(デフォルト)の変数 */
11 | --background-color: #f8f8f8;
12 | --text-color: #1a1a1a;
13 | --node-bg: #ffffff;
14 | --node-border: #e2e8f0;
15 | --node-selected: #d4e6ff;
16 | --node-hover: #f0f7ff;
17 | --pr-node-bg: #ebf4ff;
18 | --commit-node-bg: #fff;
19 | --start-node-bg: #e6fffa;
20 | --edge-color: #64748b;
21 | --panel-bg: #ffffff;
22 | --panel-shadow: rgba(0, 0, 0, 0.15);
23 | --filter-button-bg: #f1f5f9;
24 | --filter-button-hover: #e2e8f0;
25 | --status-not-started-bg: #f1f5f9;
26 | --status-in-progress-bg: #eff6ff;
27 | --status-completed-bg: #f0fdf4;
28 | --status-cancelled-bg: #fef2f2;
29 | --status-blocked-bg: #fff7ed;
30 | --status-needs-refinement-bg: #fdf4ff;
31 | --status-user-review-bg: #f3e8ff;
32 | --input-bg: #ffffff;
33 | --input-border: #e2e8f0;
34 | --input-focus-border: #3b82f6;
35 | --button-primary-bg: #3b82f6;
36 | --button-primary-hover: #2563eb;
37 | --button-secondary-bg: #64748b;
38 | --button-secondary-hover: #475569;
39 | --button-danger-bg: #ef4444;
40 | --button-danger-hover: #dc2626;
41 | }
42 |
43 | /* ダークモードの変数 */
44 | .dark-mode {
45 | --background-color: #1a1a1a;
46 | --text-color: #f0f0f0;
47 | --node-bg: #2d3748;
48 | --node-border: #4a5568;
49 | --node-selected: #3b4858;
50 | --node-hover: #4a5568;
51 | --pr-node-bg: #2c3e50;
52 | --commit-node-bg: #2d3748;
53 | --edge-color: #a0aec0;
54 | --panel-bg: #2d3748;
55 | --panel-shadow: rgba(0, 0, 0, 0.3);
56 | --filter-button-bg: #4a5568;
57 | --filter-button-hover: #2d3748;
58 | --status-not-started-bg: #374151;
59 | --status-in-progress-bg: #1e3a8a;
60 | --status-completed-bg: #065f46;
61 | --status-cancelled-bg: #7f1d1d;
62 | --status-blocked-bg: #783c00;
63 | --status-needs-refinement-bg: #701a75;
64 | --status-user-review-bg: #701a75;
65 | --input-bg: #4a5568;
66 | --input-border: #64748b;
67 | --input-focus-border: #60a5fa;
68 | --button-primary-bg: #3b82f6;
69 | --button-primary-hover: #1d4ed8;
70 | --button-secondary-bg: #64748b;
71 | --button-secondary-hover: #475569;
72 | --button-danger-bg: #ef4444;
73 | --button-danger-hover: #b91c1c;
74 | }
75 |
76 | body {
77 | margin: 0;
78 | display: flex;
79 | place-items: center;
80 | min-width: 320px;
81 | min-height: 100vh;
82 | background-color: var(--background-color);
83 | color: var(--text-color);
84 | transition: color 0.2s, background-color 0.2s;
85 | }
86 |
87 | h1 {
88 | font-size: 3.2em;
89 | line-height: 1.1;
90 | }
91 |
92 | button {
93 | border-radius: 8px;
94 | border: 1px solid transparent;
95 | padding: 0.6em 1.2em;
96 | font-size: 1em;
97 | font-weight: 500;
98 | font-family: inherit;
99 | background-color: #1a1a1a;
100 | cursor: pointer;
101 | transition: border-color 0.25s;
102 | }
103 | button:hover {
104 | border-color: #646cff;
105 | }
106 | button:focus,
107 | button:focus-visible {
108 | outline: 4px auto -webkit-focus-ring-color;
109 | }
110 |
111 | @media (prefers-color-scheme: light) {
112 | :root {
113 | color: #213547;
114 | background-color: #ffffff;
115 | }
116 | a:hover {
117 | color: #747bff;
118 | }
119 | button {
120 | background-color: #f9f9f9;
121 | }
122 | }
123 |
124 | /* 全幅表示用のスタイル */
125 | #root {
126 | width: 100%;
127 | height: 100vh;
128 | margin: 0;
129 | padding: 0;
130 | display: flex;
131 | flex-direction: column;
132 | }
133 |
134 | /* ReactFlowのカスタマイズ */
135 |
136 |
137 |
138 | .animate-pulse {
139 | animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
140 | }
141 |
142 | /* ReactFlow ダークモード対応 */
143 | .dark-mode .react-flow__node {
144 | background-color: var(--node-bg);
145 | color: var(--text-color);
146 | border-color: var(--node-border);
147 | }
148 |
149 | .dark-mode .react-flow__edge-path {
150 | stroke: var(--edge-color);
151 | }
152 |
153 | .dark-mode .react-flow__controls {
154 | background-color: var(--panel-bg);
155 | box-shadow: 0 0 2px 1px var(--panel-shadow);
156 | }
157 |
158 | .dark-mode .react-flow__controls-button {
159 | background-color: var(--filter-button-bg);
160 | color: var(--text-color);
161 | border-color: var(--node-border);
162 | }
163 |
164 | .dark-mode .react-flow__controls-button:hover {
165 | background-color: var(--filter-button-hover);
166 | }
167 |
168 | .dark-mode .react-flow__background {
169 | background-color: var(--background-color);
170 | }
171 |
172 | .dark-mode .react-flow__attribution {
173 | background-color: var(--panel-bg);
174 | color: var(--text-color);
175 | }
176 |
177 | .dark-mode .react-flow__handle {
178 | background-color: var(--button-primary-bg);
179 | border-color: var(--node-border);
180 | }
181 |
182 | .dark-mode .react-flow__edge-text {
183 | fill: var(--text-color);
184 | }
185 |
186 | .dark-mode .react-flow__minimap {
187 | background-color: var(--panel-bg);
188 | }
189 |
190 | .dark-mode .react-flow__minimap-mask {
191 | fill: var(--panel-bg);
192 | }
193 |
194 | .dark-mode .react-flow__minimap-node {
195 | fill: var(--node-bg);
196 | stroke: var(--node-border);
197 | }
198 |
199 | /* コンポーネントのダークモード対応 */
200 | .dark-mode button {
201 | background-color: var(--button-secondary-bg);
202 | color: white;
203 | }
204 |
205 | .dark-mode button:hover {
206 | background-color: var(--button-secondary-hover);
207 | }
208 |
209 | .dark-mode input, .dark-mode select, .dark-mode textarea {
210 | background-color: var(--input-bg);
211 | color: var(--text-color);
212 | border-color: var(--input-border);
213 | }
214 |
215 | .dark-mode input::placeholder, .dark-mode textarea::placeholder {
216 | color: #9ca3af;
217 | }
218 |
219 | .dark-mode input:focus, .dark-mode select:focus, .dark-mode textarea:focus {
220 | border-color: var(--input-focus-border);
221 | outline: none;
222 | box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
223 | }
224 |
225 | /* 特定のコンポーネントスタイル */
226 | .dark-mode .filter-panel {
227 | background-color: var(--panel-bg);
228 | box-shadow: 0 4px 6px var(--panel-shadow);
229 | }
230 |
231 | .dark-mode .export-panel {
232 | background-color: var(--panel-bg);
233 | box-shadow: 0 4px 6px var(--panel-shadow);
234 | }
235 |
236 | /* Node 特定のスタイル */
237 | .dark-mode .start-node {
238 | background-color: var(--start-node-bg);
239 | }
240 |
241 | .dark-mode .pr-node {
242 | background-color: var(--pr-node-bg);
243 | }
244 |
245 | .dark-mode .commit-node {
246 | background-color: var(--commit-node-bg);
247 | }
248 |
249 | /* ステータスラベルのダークモード対応 */
250 | .dark-mode .status-badge.not_started {
251 | background-color: var(--status-not-started-bg);
252 | }
253 |
254 | .dark-mode .status-badge.in_progress {
255 | background-color: var(--status-in-progress-bg);
256 | }
257 |
258 | .dark-mode .status-badge.completed {
259 | background-color: var(--status-completed-bg);
260 | }
261 |
262 | .dark-mode .status-badge.cancelled {
263 | background-color: var(--status-cancelled-bg);
264 | }
265 |
266 | .dark-mode .status-badge.blocked {
267 | background-color: var(--status-blocked-bg);
268 | }
269 |
270 | .dark-mode .status-badge.needsRefinment {
271 | background-color: var(--status-needs-refinement-bg);
272 | }
273 |
274 | .dark-mode .status-badge.user_review {
275 | background-color: var(--status-user-review-bg);
276 | }
277 |
278 | /* 一般的なトランジション */
279 | .transition-all {
280 | transition: all 0.2s ease;
281 | }
282 |
283 | /* アクセシビリティ */
284 | .visually-hidden {
285 | position: absolute;
286 | width: 1px;
287 | height: 1px;
288 | padding: 0;
289 | margin: -1px;
290 | overflow: hidden;
291 | clip: rect(0, 0, 0, 0);
292 | border: 0;
293 | }
294 |
295 | /* フォーカス可視性 */
296 | :focus-visible {
297 | outline: 2px solid var(--input-focus-border);
298 | outline-offset: 2px;
299 | }
300 |
301 | /* ダークモードでのスクロールバーカスタマイズ */
302 | .dark-mode::-webkit-scrollbar {
303 | width: 8px;
304 | height: 8px;
305 | }
306 |
307 | .dark-mode::-webkit-scrollbar-track {
308 | background: #2d3748;
309 | }
310 |
311 | .dark-mode::-webkit-scrollbar-thumb {
312 | background-color: #4a5568;
313 | border-radius: 4px;
314 | }
315 |
316 | .dark-mode::-webkit-scrollbar-thumb:hover {
317 | background-color: #a0aec0;
318 | }
319 |
320 | /* レスポンシブメディアクエリ */
321 | @media (max-width: 768px) {
322 | button, input, select {
323 | font-size: 14px;
324 | }
325 |
326 | .dark-mode .react-flow__controls {
327 | transform: scale(0.8);
328 | transform-origin: bottom right;
329 | }
330 | }
331 |
332 | /* ステータスバッジのデフォルトスタイル - ライトモード */
333 | .status-badge.not_started {
334 | background-color: var(--status-not-started-bg);
335 | }
336 |
337 | .status-badge.in_progress {
338 | background-color: var(--status-in-progress-bg);
339 | }
340 |
341 | .status-badge.completed {
342 | background-color: var(--status-completed-bg);
343 | }
344 |
345 | .status-badge.cancelled {
346 | background-color: var(--status-cancelled-bg);
347 | }
348 |
349 | .status-badge.blocked {
350 | background-color: var(--status-blocked-bg);
351 | }
352 |
353 | .status-badge.needsRefinment {
354 | background-color: var(--status-needs-refinement-bg);
355 | }
356 |
357 | .status-badge.user_review {
358 | background-color: var(--status-user-review-bg);
359 | }
360 |
361 | /* ステータスバッジのダークモードスタイル */
362 | .dark-mode .status-badge.not_started {
363 | background-color: var(--status-not-started-bg);
364 | }
365 |
```
--------------------------------------------------------------------------------
/visualization/src/components/ExportPanel.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { useState, useRef, useEffect } from 'react';
2 | import { WorkPlan } from '../types';
3 | import {
4 | downloadAsJSON,
5 | downloadAsMarkdown,
6 | downloadAsCSV
7 | } from '../utils/exportUtils';
8 |
9 | interface ExportPanelProps {
10 | workplan: WorkPlan;
11 | isOpen: boolean;
12 | onClose: () => void;
13 | }
14 |
15 | const ExportPanel: React.FC<ExportPanelProps> = ({
16 | workplan,
17 | isOpen,
18 | onClose
19 | }) => {
20 | const [fileName, setFileName] = useState('workplan');
21 | const [isDownloading, setIsDownloading] = useState<string | null>(null);
22 | const panelRef = useRef<HTMLDivElement>(null);
23 |
24 | // Close when clicking outside the panel
25 | useEffect(() => {
26 | const handleOutsideClick = (event: MouseEvent) => {
27 | if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
28 | onClose();
29 | }
30 | };
31 |
32 | if (isOpen) {
33 | document.addEventListener('mousedown', handleOutsideClick);
34 | }
35 |
36 | return () => {
37 | document.removeEventListener('mousedown', handleOutsideClick);
38 | };
39 | }, [isOpen, onClose]);
40 |
41 | // Close panel with Esc key
42 | useEffect(() => {
43 | const handleKeyDown = (event: KeyboardEvent) => {
44 | if (event.key === 'Escape') {
45 | onClose();
46 | }
47 | };
48 |
49 | if (isOpen) {
50 | document.addEventListener('keydown', handleKeyDown);
51 | }
52 |
53 | return () => {
54 | document.removeEventListener('keydown', handleKeyDown);
55 | };
56 | }, [isOpen, onClose]);
57 |
58 | if (!isOpen) return null;
59 |
60 | // Common download handler
61 | const handleDownload = async (format: string, downloadFn: () => void) => {
62 | setIsDownloading(format);
63 | try {
64 | // Add a slight delay to provide user feedback
65 | await new Promise(resolve => setTimeout(resolve, 300));
66 | downloadFn();
67 | } finally {
68 | setTimeout(() => {
69 | setIsDownloading(null);
70 | }, 500);
71 | }
72 | };
73 |
74 | // Export as JSON format
75 | const handleJSONExport = () => {
76 | handleDownload('json', () => downloadAsJSON(workplan, `${fileName}.json`));
77 | };
78 |
79 | // Export as Markdown format
80 | const handleMarkdownExport = () => {
81 | handleDownload('markdown', () => downloadAsMarkdown(workplan, `${fileName}.md`));
82 | };
83 |
84 | // Export as CSV format
85 | const handleCSVExport = () => {
86 | handleDownload('csv', () => downloadAsCSV(workplan, `${fileName}.csv`));
87 | };
88 |
89 | return (
90 | <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 export-panel-container p-4">
91 | <div
92 | ref={panelRef}
93 | className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md transform transition-all duration-300"
94 | role="dialog"
95 | aria-labelledby="export-panel-title"
96 | aria-modal="true"
97 | >
98 | <div className="flex justify-between items-center mb-4 border-b pb-3">
99 | <h2
100 | id="export-panel-title"
101 | className="text-xl font-bold text-gray-800 flex items-center"
102 | >
103 | <span className="mr-2">📊</span>
104 | Export
105 | </h2>
106 | <button
107 | onClick={onClose}
108 | className="text-gray-500 hover:text-gray-700 text-xl transition-colors p-1 rounded-full hover:bg-gray-100"
109 | aria-label="Close"
110 | >
111 | ×
112 | </button>
113 | </div>
114 |
115 | <div className="mb-4">
116 | <label
117 | htmlFor="fileName"
118 | className="block text-sm font-medium text-gray-700 mb-1"
119 | >
120 | Filename
121 | </label>
122 | <div className="relative">
123 | <input
124 | id="fileName"
125 | type="text"
126 | value={fileName}
127 | onChange={(e) => setFileName(e.target.value)}
128 | className="w-full p-2 pl-8 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
129 | placeholder="Enter filename"
130 | aria-describedby="filename-desc"
131 | />
132 | <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
133 | <svg className="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
134 | <path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
135 | </svg>
136 | </div>
137 | </div>
138 | <p id="filename-desc" className="text-xs text-gray-500 mt-1">
139 | *File extension will be added automatically
140 | </p>
141 | </div>
142 |
143 | <div className="grid grid-cols-1 gap-3 mb-5">
144 | <button
145 | onClick={handleJSONExport}
146 | className={`flex items-center justify-center px-4 py-3 bg-blue-50 hover:bg-blue-100 text-blue-700 font-medium rounded-md border border-blue-200 transition-all ${isDownloading === 'json' ? 'opacity-75 pointer-events-none' : ''}`}
147 | disabled={isDownloading !== null}
148 | >
149 | {isDownloading === 'json' ? (
150 | <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
151 | <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
152 | <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
153 | </svg>
154 | ) : (
155 | <span className="mr-2">📄</span>
156 | )}
157 | Export as JSON
158 | </button>
159 |
160 | <button
161 | onClick={handleMarkdownExport}
162 | className={`flex items-center justify-center px-4 py-3 bg-purple-50 hover:bg-purple-100 text-purple-700 font-medium rounded-md border border-purple-200 transition-all ${isDownloading === 'markdown' ? 'opacity-75 pointer-events-none' : ''}`}
163 | disabled={isDownloading !== null}
164 | >
165 | {isDownloading === 'markdown' ? (
166 | <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-purple-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
167 | <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
168 | <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
169 | </svg>
170 | ) : (
171 | <span className="mr-2">📝</span>
172 | )}
173 | Export as Markdown
174 | </button>
175 |
176 | <button
177 | onClick={handleCSVExport}
178 | className={`flex items-center justify-center px-4 py-3 bg-green-50 hover:bg-green-100 text-green-700 font-medium rounded-md border border-green-200 transition-all ${isDownloading === 'csv' ? 'opacity-75 pointer-events-none' : ''}`}
179 | disabled={isDownloading !== null}
180 | >
181 | {isDownloading === 'csv' ? (
182 | <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-green-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
183 | <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
184 | <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
185 | </svg>
186 | ) : (
187 | <span className="mr-2">📊</span>
188 | )}
189 | Export as CSV
190 | </button>
191 | </div>
192 |
193 | <div className="bg-gray-50 p-3 rounded-md border border-gray-100">
194 | <h3 className="text-sm font-medium text-gray-700 mb-2">Export Format Explanation:</h3>
195 | <ul className="list-disc pl-5 space-y-1.5 text-xs text-gray-600">
196 | <li>
197 | <span className="font-medium text-blue-600">JSON:</span> Original data format. Ideal for reuse or import in other applications.
198 | </li>
199 | <li>
200 | <span className="font-medium text-purple-600">Markdown:</span> Readable document format. Can be displayed in GitHub and similar platforms.
201 | </li>
202 | <li>
203 | <span className="font-medium text-green-600">CSV:</span> Format for spreadsheet software. Suitable for analysis in Excel and similar tools.
204 | </li>
205 | </ul>
206 | </div>
207 |
208 | <div className="mt-4 flex justify-end">
209 | <button
210 | onClick={onClose}
211 | className="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded text-gray-700 text-sm transition-colors"
212 | >
213 | Close
214 | </button>
215 | </div>
216 | </div>
217 | </div>
218 | );
219 | };
220 |
221 | export default ExportPanel;
```
--------------------------------------------------------------------------------
/visualization/src/components/WorkplanFlow.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { useState, useCallback, useMemo } from 'react';
2 | import ReactFlow, {
3 | MiniMap,
4 | Controls,
5 | Background,
6 | useNodesState,
7 | useEdgesState,
8 | NodeTypes,
9 | EdgeTypes,
10 | Panel,
11 | BackgroundVariant,
12 | getBezierPath,
13 | EdgeProps,
14 | MarkerType,
15 | Position,
16 | } from 'reactflow';
17 | import 'reactflow/dist/style.css';
18 | import { WorkPlan, NodeData, ExtendedNode, CommitStatus } from '../types';
19 | import {
20 | convertWorkPlanToFlow,
21 | filterWorkplan,
22 | filterNodesAndEdges
23 | } from '../utils/workplanConverter';
24 | import { useResponsiveFlowDimensions } from '../utils/responsiveUtils';
25 | import CommitNode from './CommitNode';
26 | import { FilterOptions } from '../components/FilterPanel';
27 | import './nodes/nodes.css';
28 |
29 | export interface WorkplanFlowProps {
30 | workplan: WorkPlan;
31 | filterOptions: FilterOptions;
32 | }
33 |
34 | // Custom edge component
35 | const CustomEdge = ({
36 | id,
37 | sourceX,
38 | sourceY,
39 | targetX,
40 | targetY,
41 | sourcePosition,
42 | targetPosition,
43 | style = {},
44 | data,
45 | markerEnd,
46 | animated
47 | }: EdgeProps) => {
48 | // Adjust connection point calculation
49 | // Ensure targetPosition is set to Position.Left
50 | const targetPos = targetPosition || Position.Left;
51 | const sourcePos = sourcePosition || Position.Right;
52 |
53 | // Adjust arguments for getBezierPath
54 | const [edgePath, labelX, labelY] = getBezierPath({
55 | sourceX,
56 | sourceY,
57 | sourcePosition: sourcePos,
58 | targetX,
59 | targetY,
60 | targetPosition: targetPos,
61 | curvature: 0.4
62 | });
63 |
64 | return (
65 | <>
66 | <path
67 | id={id}
68 | style={{
69 | ...style,
70 | strokeWidth: style.strokeWidth || 2,
71 | stroke: style.stroke || '#555',
72 | transition: 'stroke 0.3s, stroke-width 0.3s',
73 | }}
74 | className={`react-flow__edge-path ${animated ? 'animated' : ''}`}
75 | d={edgePath}
76 | markerEnd={markerEnd}
77 | />
78 | {data?.label && (
79 | <text
80 | x={labelX}
81 | y={labelY}
82 | style={{
83 | fontSize: '10px',
84 | fill: '#666',
85 | textAnchor: 'middle',
86 | dominantBaseline: 'middle',
87 | pointerEvents: 'none',
88 | fontWeight: 'normal',
89 | }}
90 | className="react-flow__edge-text"
91 | >
92 | {data.label}
93 | </text>
94 | )}
95 | </>
96 | );
97 | };
98 |
99 | // Register custom node types
100 | const nodeTypes: NodeTypes = {
101 | commitNode: CommitNode,
102 | };
103 |
104 | // Register custom edge types
105 | const edgeTypes: EdgeTypes = {
106 | custom: CustomEdge,
107 | };
108 |
109 | // Status label definitions
110 | const statusLabels: Record<CommitStatus, string> = {
111 | 'not_started': 'Not Started',
112 | 'in_progress': 'In Progress',
113 | 'completed': 'Completed',
114 | 'cancelled': 'Cancelled',
115 | 'needsRefinment': 'Needs Refinement',
116 | 'user_review': 'Awaiting User Review'
117 | };
118 |
119 |
120 | const WorkplanFlow = ({
121 | workplan,
122 | filterOptions
123 | }: WorkplanFlowProps) => {
124 | // Default filter options
125 | const defaultFilterOptions: FilterOptions = {
126 | statusFilter: 'all',
127 | searchQuery: '',
128 | onlyShowActive: false
129 | };
130 |
131 | // Get responsive settings
132 | const {
133 | miniMapVisible,
134 | controlsStyle,
135 | miniMapStyle,
136 | currentBreakpoint
137 | } = useResponsiveFlowDimensions();
138 |
139 | // Use provided filter options or default
140 | const activeFilterOptions = filterOptions || defaultFilterOptions;
141 |
142 | // Apply filtering
143 | const filteredWorkplan = useMemo(() => {
144 | return filterWorkplan(workplan, activeFilterOptions);
145 | }, [workplan, activeFilterOptions]);
146 |
147 | // Set initial nodes and edges (generated from filtered workplan)
148 | const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
149 | return convertWorkPlanToFlow(filteredWorkplan);
150 | }, [filteredWorkplan]);
151 |
152 | // Add callbacks to commit nodes
153 | const nodesWithCallbacks = useMemo(() => {
154 | return initialNodes.map(node => {
155 | if (node.type === 'commitNode') {
156 | const nodeData = node.data as any; // Temporarily handle as any
157 | return {
158 | ...node,
159 | data: {
160 | ...nodeData,
161 | // Fallback if data doesn't have necessary properties
162 | title: nodeData.title || nodeData.label || '',
163 | status: nodeData.status || 'not_started'
164 | }
165 | };
166 | }
167 | return node;
168 | });
169 | }, [initialNodes]);
170 |
171 | // Change edge type to custom
172 | const customEdges = useMemo(() => {
173 | return initialEdges.map(edge => ({
174 | ...edge,
175 | type: 'custom',
176 | data: { label: edge.label },
177 | markerEnd: {
178 | type: MarkerType.ArrowClosed,
179 | color: edge.style?.stroke || '#555',
180 | },
181 | }));
182 | }, [initialEdges]);
183 |
184 | // Apply filtering directly to nodes and edges
185 | const { nodes: filteredNodes, edges: filteredEdges } = useMemo(() => {
186 | return filterNodesAndEdges(nodesWithCallbacks, customEdges, activeFilterOptions);
187 | }, [nodesWithCallbacks, customEdges, activeFilterOptions]);
188 |
189 | // Use handlers for interaction only
190 | const [, , onNodesChange] = useNodesState([]);
191 | const [, , onEdgesChange] = useEdgesState([]);
192 |
193 | // Selected node information
194 | const [selectedNode, setSelectedNode] = useState<ExtendedNode<NodeData> | null>(null);
195 |
196 | // Handler for node selection
197 | const onNodeClick = useCallback((event: React.MouseEvent, node: ExtendedNode) => {
198 | setSelectedNode(node as ExtendedNode<NodeData>);
199 | }, []);
200 |
201 | // Handler for canvas click (deselection)
202 | const onPaneClick = useCallback(() => {
203 | setSelectedNode(null);
204 | }, []);
205 |
206 | // Flow component style
207 | const proOptions = { hideAttribution: true };
208 |
209 | // FitView options based on current breakpoint
210 | const fitViewOptions = useMemo(() => ({
211 | padding: currentBreakpoint === 'xs' ? 0.1 :
212 | currentBreakpoint === 'sm' ? 0.15 : 0.2,
213 | maxZoom: 1.5,
214 | includeHiddenNodes: false,
215 | minZoom: 0.2,
216 | alignmentX: 0.5, // Horizontal center
217 | alignmentY: 0, // Top alignment
218 | }), [currentBreakpoint]);
219 |
220 | // Initial viewport settings
221 | const defaultViewport = { x: 0, y: 0, zoom: 1 };
222 |
223 | return (
224 | <div style={{ width: '100%', height: '100vh', position: 'relative' }}>
225 | <ReactFlow
226 | nodes={filteredNodes}
227 | edges={filteredEdges}
228 | onNodesChange={onNodesChange}
229 | onEdgesChange={onEdgesChange}
230 | onNodeClick={onNodeClick}
231 | onPaneClick={onPaneClick}
232 | nodeTypes={nodeTypes}
233 | edgeTypes={edgeTypes}
234 | fitView
235 | fitViewOptions={fitViewOptions}
236 | defaultViewport={defaultViewport}
237 | proOptions={proOptions}
238 | minZoom={0.1}
239 | maxZoom={2}
240 | >
241 | {/* Display active filters - responsive */}
242 | {(activeFilterOptions.statusFilter !== 'all' ||
243 | activeFilterOptions.searchQuery.trim() ||
244 | activeFilterOptions.onlyShowActive) && (
245 | <Panel
246 | position="top-left"
247 | className={`bg-white p-2 rounded-lg shadow-md border border-gray-200 ${
248 | currentBreakpoint === 'xs' ? 'text-xs max-w-[80vw]' : ''
249 | }`}
250 | >
251 | <div className={`text-gray-700 ${currentBreakpoint === 'xs' ? 'text-xs' : 'text-sm'}`}>
252 | <span className="font-bold">Filters Applied:</span>
253 | <div className={`flex flex-wrap gap-1 mt-1 ${currentBreakpoint === 'xs' ? 'max-w-full' : ''}`}>
254 | {activeFilterOptions.statusFilter !== 'all' && (
255 | <span className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded-full text-xs">
256 | Status: {activeFilterOptions.statusFilter}
257 | </span>
258 | )}
259 | {activeFilterOptions.searchQuery.trim() && (
260 | <span className="px-2 py-0.5 bg-green-100 text-green-800 rounded-full text-xs">
261 | Search: {activeFilterOptions.searchQuery}
262 | </span>
263 | )}
264 | {activeFilterOptions.onlyShowActive && (
265 | <span className="px-2 py-0.5 bg-yellow-100 text-yellow-800 rounded-full text-xs">
266 | Active Only
267 | </span>
268 | )}
269 | </div>
270 | </div>
271 | </Panel>
272 | )}
273 |
274 | {/* Mini map - responsive */}
275 | {miniMapVisible && (
276 | <MiniMap
277 | style={miniMapStyle}
278 | nodeStrokeWidth={3}
279 | zoomable
280 | pannable
281 | />
282 | )}
283 |
284 | {/* Controls - responsive */}
285 | <Controls style={controlsStyle} />
286 |
287 | <Background
288 | variant={BackgroundVariant.Dots}
289 | gap={12}
290 | size={1}
291 | />
292 |
293 | {/* Help information for mobile display */}
294 | {currentBreakpoint === 'xs' && (
295 | <Panel position="bottom-center" className="p-2 bg-white bg-opacity-80 rounded text-xs text-center">
296 | Pinch to zoom, swipe to move
297 | </Panel>
298 | )}
299 | </ReactFlow>
300 |
301 | {/* Detailed information for selected node - responsive */}
302 | {selectedNode && (
303 | <div
304 | className={`absolute p-3 bg-white dark:bg-gray-800 border rounded-md shadow-lg
305 | ${currentBreakpoint === 'xs' ? 'left-2 right-2 bottom-2' : 'right-4 top-4 w-64'}`}
306 | >
307 | <div className="flex justify-between mb-2">
308 | <h3 className="font-bold">Details</h3>
309 | <button
310 | onClick={onPaneClick}
311 | className="text-gray-500 hover:text-gray-700"
312 | >
313 | ✕
314 | </button>
315 | </div>
316 | <div className="text-sm">
317 | <p><strong>Title:</strong> {selectedNode.data.label}</p>
318 | <p><strong>Status:</strong> {selectedNode.data.status && statusLabels[selectedNode.data.status]}</p>
319 | {selectedNode.data.description && (
320 | <p><strong>Description:</strong> {selectedNode.data.description}</p>
321 | )}
322 | </div>
323 | </div>
324 | )}
325 | </div>
326 | );
327 | };
328 |
329 | export default WorkplanFlow;
```
--------------------------------------------------------------------------------
/visualization/src/components/FilterPanel.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { useState, useEffect, useRef, useCallback } from 'react';
2 | import { CommitStatus } from '../types';
3 |
4 | // Filter settings type
5 | export interface FilterOptions {
6 | statusFilter: CommitStatus | 'all';
7 | searchQuery: string;
8 | onlyShowActive: boolean; // Show only active PRs/commits
9 | }
10 |
11 | interface FilterPanelProps {
12 | options: FilterOptions;
13 | onChange: (newOptions: FilterOptions) => void;
14 | isOpen: boolean;
15 | onClose: () => void;
16 | }
17 |
18 | // Status display name mapping
19 | const STATUS_LABELS: Record<CommitStatus | 'all', string> = {
20 | 'all': 'All',
21 | 'not_started': 'Not Started',
22 | 'in_progress': 'In Progress',
23 | 'blocked': 'Blocked',
24 | 'completed': 'Completed',
25 | 'cancelled': 'Cancelled',
26 | 'needsRefinment': 'Needs Refinement',
27 | 'user_review': 'Awaiting User Review'
28 | };
29 |
30 | // Status icon mapping
31 | const STATUS_ICONS: Record<CommitStatus | 'all', string> = {
32 | 'all': '🔍',
33 | 'not_started': '⚪',
34 | 'in_progress': '🔄',
35 | 'blocked': '⛔',
36 | 'completed': '✅',
37 | 'cancelled': '❌',
38 | 'needsRefinment': '⚠️',
39 | 'user_review': '👀'
40 | };
41 |
42 | // Pre-generate status options
43 | const STATUS_OPTIONS = Object.entries(STATUS_LABELS).map(([status, label]) => (
44 | <option key={status} value={status}>
45 | {STATUS_ICONS[status as CommitStatus | 'all']} {label}
46 | </option>
47 | ));
48 |
49 | const FilterPanel: React.FC<FilterPanelProps> = ({
50 | options,
51 | onChange,
52 | isOpen,
53 | onClose
54 | }) => {
55 | const [localOptions, setLocalOptions] = useState<FilterOptions>(options);
56 | const panelRef = useRef<HTMLDivElement>(null);
57 |
58 | // Update internal state when options from props change
59 | useEffect(() => {
60 | setLocalOptions(options);
61 | }, [options]);
62 |
63 | // Change handler (memoized)
64 | const handleChange = useCallback((key: keyof FilterOptions, value: any) => {
65 | const newOptions = { ...localOptions, [key]: value };
66 | setLocalOptions(newOptions);
67 | onChange(newOptions);
68 | }, [localOptions, onChange]);
69 |
70 | // Reset handler (memoized)
71 | const handleReset = useCallback(() => {
72 | const defaultOptions: FilterOptions = {
73 | statusFilter: 'all',
74 | searchQuery: '',
75 | onlyShowActive: false
76 | };
77 | setLocalOptions(defaultOptions);
78 | onChange(defaultOptions);
79 | }, [onChange]);
80 |
81 | // Close when clicking outside the panel
82 | useEffect(() => {
83 | if (!isOpen) return;
84 |
85 | const handleOutsideClick = (event: MouseEvent) => {
86 | if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
87 | onClose();
88 | }
89 | };
90 |
91 | document.addEventListener('mousedown', handleOutsideClick);
92 | return () => document.removeEventListener('mousedown', handleOutsideClick);
93 | }, [isOpen, onClose]);
94 |
95 | // Close panel with Esc key
96 | useEffect(() => {
97 | if (!isOpen) return;
98 |
99 | const handleKeyDown = (event: KeyboardEvent) => {
100 | if (event.key === 'Escape') {
101 | onClose();
102 | }
103 | };
104 |
105 | document.addEventListener('keydown', handleKeyDown);
106 | return () => document.removeEventListener('keydown', handleKeyDown);
107 | }, [isOpen, onClose]);
108 |
109 | if (!isOpen) return null;
110 |
111 | return (
112 | <div
113 | ref={panelRef}
114 | className="bg-white rounded-lg shadow-lg p-4 border border-gray-200 transition-all duration-300"
115 | >
116 | <div className="flex justify-between items-center mb-4">
117 | <h3 className="text-lg font-semibold text-gray-800 flex items-center">
118 | <span className="mr-2">🔍</span>
119 | Filter Settings
120 | </h3>
121 | <button
122 | onClick={onClose}
123 | className="text-gray-500 hover:text-gray-700 text-xl transition-colors p-1 rounded-full hover:bg-gray-100"
124 | aria-label="Close"
125 | >
126 | ×
127 | </button>
128 | </div>
129 |
130 | <div className="space-y-4">
131 | {/* Status filter */}
132 | <div className="transition-all duration-200 hover:shadow-sm">
133 | <label className="block text-sm font-medium text-gray-700 mb-1">
134 | Status
135 | </label>
136 | <div className="relative">
137 | <select
138 | value={localOptions.statusFilter}
139 | onChange={(e) => handleChange('statusFilter', e.target.value)}
140 | className="w-full p-2 pl-8 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none"
141 | >
142 | {STATUS_OPTIONS}
143 | </select>
144 | <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
145 | {STATUS_ICONS[localOptions.statusFilter as CommitStatus | 'all']}
146 | </div>
147 | <div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
148 | <svg className="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
149 | <path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
150 | </svg>
151 | </div>
152 | </div>
153 | </div>
154 |
155 | {/* Search filter */}
156 | <div className="transition-all duration-200 hover:shadow-sm">
157 | <label className="block text-sm font-medium text-gray-700 mb-1">
158 | Search
159 | </label>
160 | <div className="relative">
161 | <input
162 | type="text"
163 | value={localOptions.searchQuery}
164 | onChange={(e) => handleChange('searchQuery', e.target.value)}
165 | placeholder="Search in commit content..."
166 | className="w-full p-2 pl-8 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
167 | />
168 | <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
169 | <svg className="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
170 | <path fillRule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clipRule="evenodd" />
171 | </svg>
172 | </div>
173 | {localOptions.searchQuery && (
174 | <button
175 | onClick={() => handleChange('searchQuery', '')}
176 | className="absolute inset-y-0 right-0 flex items-center pr-2 text-gray-400 hover:text-gray-600"
177 | aria-label="Clear search"
178 | >
179 | <svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
180 | <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
181 | </svg>
182 | </button>
183 | )}
184 | </div>
185 | </div>
186 |
187 | {/* Show only active items */}
188 | <div className="flex items-center p-2 hover:bg-gray-50 rounded-md transition-colors">
189 | <input
190 | type="checkbox"
191 | id="onlyActive"
192 | checked={localOptions.onlyShowActive}
193 | onChange={(e) => handleChange('onlyShowActive', e.target.checked)}
194 | className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors"
195 | />
196 | <label htmlFor="onlyActive" className="ml-2 block text-sm text-gray-700">
197 | Show only active tasks
198 | </label>
199 | </div>
200 |
201 | {/* Filter badge display */}
202 | {(localOptions.statusFilter !== 'all' ||
203 | localOptions.searchQuery.trim() ||
204 | localOptions.onlyShowActive) && (
205 | <div className="flex flex-wrap gap-2 pt-2 pb-3">
206 | <p className="w-full text-xs text-gray-500 mb-1">Active filters:</p>
207 |
208 | {localOptions.statusFilter !== 'all' && (
209 | <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
210 | {STATUS_ICONS[localOptions.statusFilter as CommitStatus]}
211 | <span className="ml-1">{STATUS_LABELS[localOptions.statusFilter as CommitStatus]}</span>
212 | <button
213 | onClick={() => handleChange('statusFilter', 'all')}
214 | className="ml-1 text-blue-500 hover:text-blue-700"
215 | >
216 | ×
217 | </button>
218 | </span>
219 | )}
220 |
221 | {localOptions.searchQuery.trim() && (
222 | <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
223 | 🔍 {localOptions.searchQuery}
224 | <button
225 | onClick={() => handleChange('searchQuery', '')}
226 | className="ml-1 text-green-500 hover:text-green-700"
227 | >
228 | ×
229 | </button>
230 | </span>
231 | )}
232 |
233 | {localOptions.onlyShowActive && (
234 | <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
235 | 🔄 Active Only
236 | <button
237 | onClick={() => handleChange('onlyShowActive', false)}
238 | className="ml-1 text-yellow-500 hover:text-yellow-700"
239 | >
240 | ×
241 | </button>
242 | </span>
243 | )}
244 | </div>
245 | )}
246 |
247 | {/* Reset button */}
248 | <div className="pt-2 flex justify-end">
249 | <button
250 | onClick={handleReset}
251 | className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded text-gray-700 text-sm transition-colors flex items-center"
252 | >
253 | <svg className="w-4 h-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
254 | <path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
255 | </svg>
256 | Reset
257 | </button>
258 | </div>
259 | </div>
260 | </div>
261 | );
262 | };
263 |
264 | export default FilterPanel;
```
--------------------------------------------------------------------------------
/visualization/src/utils/workplanConverter.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Edge, Position } from 'reactflow';
2 | import { WorkPlan, CommitStatus, NodeData, ExtendedNode } from '../types';
3 | import { CommitNodeData } from '../components/CommitNode';
4 | import { FilterOptions } from '../components/FilterPanel';
5 |
6 | // Function to return color based on status
7 | export const getStatusColor = (status: CommitStatus): string => {
8 | switch (status) {
9 | case 'completed':
10 | return '#10b981'; // green
11 | case 'in_progress':
12 | return '#3b82f6'; // blue
13 | case 'cancelled':
14 | return '#6b7280'; // gray
15 | case 'needsRefinment':
16 | return '#f59e0b'; // orange
17 | case 'user_review':
18 | return '#9333ea'; // purple
19 | case 'not_started':
20 | default:
21 | return '#94a3b8'; // light gray
22 | }
23 | };
24 |
25 | // Function to return background color based on status
26 | const getStatusBackgroundColor = (status: CommitStatus): string => {
27 | switch (status) {
28 | case 'completed':
29 | return '#d1fae5'; // light green
30 | case 'in_progress':
31 | return '#dbeafe'; // light blue
32 | case 'cancelled':
33 | return '#f3f4f6'; // light gray
34 | case 'needsRefinment':
35 | return '#fef3c7'; // light orange
36 | case 'user_review':
37 | return '#f3e8ff'; // light purple
38 | case 'not_started':
39 | default:
40 | return '#f1f5f9'; // light gray
41 | }
42 | };
43 |
44 | // Function to return label based on status
45 | const getStatusLabel = (status: CommitStatus): string => {
46 | switch (status) {
47 | case 'completed':
48 | return 'Completed';
49 | case 'in_progress':
50 | return 'In Progress';
51 | case 'cancelled':
52 | return 'Cancelled';
53 | case 'needsRefinment':
54 | return 'Needs Refinement';
55 | case 'user_review':
56 | return 'Waiting for User Review';
57 | case 'not_started':
58 | default:
59 | return 'Not Started';
60 | }
61 | };
62 |
63 | // Function to return icon based on status
64 | const getStatusIcon = (status: CommitStatus): string => {
65 | switch (status) {
66 | case 'completed':
67 | return '✅';
68 | case 'in_progress':
69 | return '🔄';
70 | case 'cancelled':
71 | return '❌';
72 | case 'needsRefinment':
73 | return '🔍';
74 | case 'user_review':
75 | return '👀';
76 | case 'not_started':
77 | default:
78 | return '⏳';
79 | }
80 | };
81 |
82 | // Layout constants
83 | const DEFAULT_LAYOUT = {
84 | HORIZONTAL_SPACING: 550, // Horizontal spacing between PR nodes
85 | PR_WIDTH: 200, // Width of PR nodes
86 | COMMIT_WIDTH: 240, // Width of commit nodes
87 | COMMIT_HEIGHT: 100, // Height of commit nodes
88 | VERTICAL_CENTER: 100, // Vertical center position (top alignment)
89 | COMMIT_HORIZONTAL_OFFSET: 250, // Horizontal offset for commit nodes to the right of PR
90 | INITIAL_X: 100, // Initial X coordinate for the first node
91 | };
92 |
93 | // Interface for responsive layout options
94 | export interface LayoutOptions {
95 | nodePadding?: number;
96 | nodeSpacing?: number;
97 | }
98 |
99 | /**
100 | * Function to convert workplan to react-flow nodes and edges
101 | * @param workplan Workplan
102 | * @param options Responsive layout options
103 | * @returns Object containing nodes and edges
104 | */
105 | export const convertWorkPlanToFlow = (workplan: WorkPlan, options?: LayoutOptions) => {
106 | if (!workplan) return { nodes: [], edges: [] };
107 |
108 | // Initialize arrays for nodes and edges
109 | const nodes: ExtendedNode<NodeData | CommitNodeData>[] = [];
110 | const edges: Edge[] = [];
111 |
112 | // Apply spacing and padding factors
113 | const spacing = options?.nodeSpacing ?? 1;
114 | const padding = options?.nodePadding ?? 1;
115 |
116 | // Calculate responsive layout values
117 | const layout = {
118 | HORIZONTAL_SPACING: DEFAULT_LAYOUT.HORIZONTAL_SPACING * spacing,
119 | PR_WIDTH: DEFAULT_LAYOUT.PR_WIDTH * padding,
120 | COMMIT_WIDTH: DEFAULT_LAYOUT.COMMIT_WIDTH * padding,
121 | COMMIT_HEIGHT: DEFAULT_LAYOUT.COMMIT_HEIGHT,
122 | VERTICAL_CENTER: DEFAULT_LAYOUT.VERTICAL_CENTER,
123 | COMMIT_HORIZONTAL_OFFSET: DEFAULT_LAYOUT.COMMIT_HORIZONTAL_OFFSET * spacing,
124 | INITIAL_X: DEFAULT_LAYOUT.INITIAL_X * spacing,
125 | };
126 |
127 | // Generate PR nodes and commit nodes
128 | workplan.prPlans.forEach((pr, prIndex) => {
129 | // PR node
130 | const prId = `pr-${prIndex}`;
131 | const prStatus = getOverallStatus(pr.commitPlans.map(commit => commit.status));
132 |
133 | const prNode: ExtendedNode<NodeData | CommitNodeData> = {
134 | id: prId,
135 | type: 'default',
136 | className: 'pr-node',
137 | position: {
138 | x: layout.INITIAL_X + layout.HORIZONTAL_SPACING * prIndex,
139 | y: layout.VERTICAL_CENTER
140 | },
141 | data: {
142 | label: pr.goal,
143 | description: `PR ${prIndex + 1}`,
144 | status: prStatus,
145 | statusLabel: getStatusLabel(prStatus),
146 | statusIcon: getStatusIcon(prStatus),
147 | developerNote: pr.developerNote
148 | },
149 | style: {
150 | width: layout.PR_WIDTH,
151 | backgroundColor: getStatusBackgroundColor(prStatus),
152 | borderColor: getStatusColor(prStatus),
153 | borderWidth: 2,
154 | },
155 | sourcePosition: Position.Right,
156 | targetPosition: Position.Left,
157 | };
158 |
159 | nodes.push(prNode);
160 |
161 | // Connect to previous PR (for second and subsequent PRs)
162 | if (prIndex > 0) {
163 | edges.push({
164 | id: `pr-${prIndex - 1}-to-${prId}`,
165 | source: `pr-${prIndex - 1}`,
166 | target: prId,
167 | animated: false,
168 | sourceHandle: 'right',
169 | targetHandle: 'left',
170 | style: {
171 | stroke: '#aaa',
172 | strokeWidth: 2,
173 | },
174 | });
175 | }
176 |
177 | // Commit node
178 | pr.commitPlans.forEach((commit, commitIndex) => {
179 | const commitId = `commit-${prIndex}-${commitIndex}`;
180 | const commitStatus = commit.status || 'not_started';
181 |
182 | // Calculate commit vertical position (spread out from PR center)
183 | const verticalOffset = (commitIndex - (pr.commitPlans.length - 1) / 2) * (layout.COMMIT_HEIGHT * 1.5);
184 |
185 | // Commit node
186 | const commitNode: ExtendedNode<CommitNodeData> = {
187 | id: commitId,
188 | type: 'commitNode',
189 | position: {
190 | x: layout.INITIAL_X + layout.HORIZONTAL_SPACING * prIndex + layout.COMMIT_HORIZONTAL_OFFSET,
191 | y: layout.VERTICAL_CENTER + verticalOffset
192 | },
193 | data: {
194 | title: commit.goal,
195 | label: commit.goal,
196 | status: commitStatus,
197 | prIndex: prIndex,
198 | commitIndex: commitIndex,
199 | developerNote: commit.developerNote
200 | },
201 | style: {
202 | borderColor: getStatusColor(commitStatus),
203 | width: layout.COMMIT_WIDTH,
204 | },
205 | sourcePosition: Position.Right,
206 | targetPosition: Position.Left,
207 | };
208 |
209 | nodes.push(commitNode);
210 |
211 | // Connect commit to PR
212 | edges.push({
213 | id: `${prId}-to-${commitId}`,
214 | source: prId,
215 | target: commitId,
216 | animated: false,
217 | sourceHandle: 'right',
218 | targetHandle: 'left',
219 | label: `Commit ${commitIndex + 1}`,
220 | style: {
221 | stroke: getStatusColor(commitStatus),
222 | strokeWidth: 2,
223 | },
224 | });
225 |
226 | // If there's a next PR, add edge from final commit to next PR
227 | if (prIndex < workplan.prPlans.length - 1 && commitIndex === pr.commitPlans.length - 1) {
228 | edges.push({
229 | id: `${commitId}-to-pr-${prIndex + 1}`,
230 | source: commitId,
231 | target: `pr-${prIndex + 1}`,
232 | animated: false,
233 | sourceHandle: 'right',
234 | targetHandle: 'left',
235 | style: {
236 | stroke: '#aaa',
237 | strokeWidth: 2,
238 | strokeDasharray: '5, 5',
239 | },
240 | });
241 | }
242 | });
243 | });
244 |
245 | return { nodes, edges };
246 | };
247 |
248 | // Function to determine overall status of PR based on commit statuses
249 | const getOverallStatus = (statuses: (CommitStatus | undefined)[]): CommitStatus => {
250 | // Treat undefined status as 'not_started'
251 | const definedStatuses = statuses.map(s => s || 'not_started');
252 |
253 | if (definedStatuses.every(status => status === 'completed')) {
254 | return 'completed';
255 | }
256 |
257 | if (definedStatuses.some(status => status === 'in_progress')) {
258 | return 'in_progress';
259 | }
260 |
261 | if (definedStatuses.some(status => status === 'needsRefinment')) {
262 | return 'needsRefinment';
263 | }
264 |
265 | if (definedStatuses.some(status => status === 'cancelled')) {
266 | return 'cancelled';
267 | }
268 |
269 | return 'not_started';
270 | };
271 |
272 | // Function to filter workplan
273 | export const filterWorkplan = (workplan: WorkPlan, options: FilterOptions): WorkPlan => {
274 | if (!workplan) return { goal: '', prPlans: [] };
275 |
276 | // Return unchanged if filtering is not needed
277 | if (options.statusFilter === 'all' && !options.searchQuery && !options.onlyShowActive) {
278 | return workplan;
279 | }
280 |
281 | // Convert search query to lowercase
282 | const searchQueryLower = options.searchQuery.toLowerCase();
283 |
284 | const filteredPrPlans = workplan.prPlans
285 | .map(pr => {
286 | // Filter commits in PR
287 | const filteredCommits = pr.commitPlans.filter(commit => {
288 | // Status filter
289 | const statusMatch = options.statusFilter === 'all' || commit.status === options.statusFilter;
290 |
291 | // Search filter
292 | const searchMatch = !searchQueryLower ||
293 | commit.goal.toLowerCase().includes(searchQueryLower);
294 |
295 | // Active filter
296 | const activeMatch = !options.onlyShowActive ||
297 | commit.status === 'in_progress' ||
298 | commit.status === 'needsRefinment';
299 |
300 | return statusMatch && searchMatch && activeMatch;
301 | });
302 |
303 | // Return PR with filtered commits
304 | return {
305 | ...pr,
306 | commitPlans: filteredCommits
307 | };
308 | })
309 | .filter(pr => {
310 | // Keep PRs with non-empty commits
311 | return pr.commitPlans.length > 0;
312 | });
313 |
314 | return {
315 | goal: workplan.goal,
316 | prPlans: filteredPrPlans
317 | };
318 | };
319 |
320 | // Function to apply direct filtering to nodes and edges
321 | export const filterNodesAndEdges = (
322 | nodes: ExtendedNode<NodeData | CommitNodeData>[],
323 | edges: Edge[],
324 | options: FilterOptions
325 | ): { nodes: ExtendedNode<NodeData | CommitNodeData>[], edges: Edge[] } => {
326 | // Return unchanged if filtering is not needed
327 | if (options.statusFilter === 'all' && !options.searchQuery && !options.onlyShowActive) {
328 | return { nodes, edges };
329 | }
330 |
331 | // Convert search query to lowercase
332 | const searchQueryLower = options.searchQuery.toLowerCase();
333 |
334 | // Apply filtering to commit nodes
335 | // PR nodes are always displayed
336 | const filteredNodeIds = new Set();
337 |
338 | // Collect displayed node IDs
339 | const filteredNodes = nodes.filter(node => {
340 | // Always display PR nodes
341 | if (node.type !== 'commitNode') {
342 | filteredNodeIds.add(node.id);
343 | return true;
344 | }
345 |
346 | // Apply filtering to commit nodes
347 | const nodeData = node.data as CommitNodeData;
348 |
349 | // Status filter
350 | const statusMatch = options.statusFilter === 'all' || nodeData.status === options.statusFilter;
351 |
352 | // Search filter
353 | const searchMatch = !searchQueryLower ||
354 | nodeData.title.toLowerCase().includes(searchQueryLower);
355 |
356 | // Active filter
357 | const activeMatch = !options.onlyShowActive ||
358 | nodeData.status === 'in_progress' ||
359 | nodeData.status === 'user_review';
360 |
361 | // If all conditions match, include this node
362 | const shouldInclude = statusMatch && searchMatch && activeMatch;
363 |
364 | if (shouldInclude) {
365 | filteredNodeIds.add(node.id);
366 | }
367 |
368 | return shouldInclude;
369 | });
370 |
371 | // Display only edges connected to filtered nodes
372 | const filteredEdges = edges.filter(edge =>
373 | filteredNodeIds.has(edge.source) && filteredNodeIds.has(edge.target)
374 | );
375 |
376 | return { nodes: filteredNodes, edges: filteredEdges };
377 | };
```
--------------------------------------------------------------------------------
/visualization/src/App.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { useState, useEffect, useCallback, useRef } from 'react'
2 | import { ReactFlowProvider } from 'reactflow'
3 | import WorkplanFlow from './components/WorkplanFlow'
4 | import FilterPanel, { FilterOptions } from './components/FilterPanel'
5 | import OrientationWarning from './components/OrientationWarning'
6 | import { WorkPlan, CommitStatus } from './types'
7 | import { useBreakpoint } from './utils/responsiveUtils'
8 | import './App.css'
9 |
10 | function App() {
11 | // Get breakpoint
12 | const breakpoint = useBreakpoint();
13 |
14 | // Initialize with normalized sample data
15 | const [workplan, setWorkplan] = useState<WorkPlan | null>(null);
16 | const [lastLoadedTime, setLastLoadedTime] = useState<Date | null>(null);
17 | const [loadError, setLoadError] = useState<string | null>(null);
18 | // Polling interval (milliseconds)
19 | const [pollingInterval, setPollingInterval] = useState<number>(5000); // Default: 1 second
20 | // Whether polling is enabled
21 | const [pollingEnabled, setPollingEnabled] = useState<boolean>(true);
22 | // Loading flag
23 | const [isLoading, setIsLoading] = useState<boolean>(false);
24 | // Track last polling attempt
25 | const pollingTimeoutRef = useRef<number | null>(null);
26 |
27 | const [showFilterPanel, setShowFilterPanel] = useState<boolean>(false);
28 | const [filterOptions, setFilterOptions] = useState<FilterOptions>({
29 | statusFilter: 'all',
30 | searchQuery: '',
31 | onlyShowActive: false
32 | });
33 |
34 | // Dark mode state
35 | const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
36 | // Load settings from localStorage
37 | const savedMode = localStorage.getItem('darkMode');
38 | // Check system color scheme settings
39 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
40 |
41 | // Use saved settings if available, otherwise follow system settings
42 | return savedMode !== null ? savedMode === 'true' : prefersDark;
43 | });
44 |
45 | // Separate data loading function for reusability
46 | const loadData = useCallback(async () => {
47 | if (isLoading) return; // Do nothing if already loading
48 |
49 | setIsLoading(true);
50 | try {
51 | // Get JSON file directly (add timestamp parameter to avoid cache)
52 | // Options to completely disable caching
53 | const fetchOptions = {
54 | method: 'GET',
55 | headers: {
56 | 'Cache-Control': 'no-cache, no-store, must-revalidate',
57 | 'Pragma': 'no-cache',
58 | 'Expires': '0'
59 | }
60 | };
61 |
62 | const timestamp = new Date().getTime();
63 | const response = await fetch(`/data/workplan.json?t=${timestamp}`, fetchOptions);
64 | if (!response.ok) {
65 | throw new Error(`Failed to fetch data. Status: ${response.status}`);
66 | }
67 |
68 | const actualWorkPlan = await response.json();
69 |
70 | // Data structure conversion
71 | const convertedWorkPlan: WorkPlan = {
72 | goal: actualWorkPlan.currentTicket.goal,
73 | prPlans: actualWorkPlan.currentTicket.pullRequests.map((pr: {
74 | goal: string;
75 | status: string;
76 | developerNote?: string;
77 | commits: Array<{
78 | goal: string;
79 | status: string;
80 | developerNote?: string;
81 | }>;
82 | }) => ({
83 | goal: pr.goal,
84 | status: pr.status as CommitStatus,
85 | developerNote: pr.developerNote,
86 | commitPlans: pr.commits.map((commit: {
87 | goal: string;
88 | status: string;
89 | developerNote?: string;
90 | }) => ({
91 | goal: commit.goal,
92 | status: commit.status as CommitStatus,
93 | developerNote: commit.developerNote
94 | }))
95 | }))
96 | };
97 |
98 | // Apply updates
99 | setWorkplan(convertedWorkPlan);
100 | setLastLoadedTime(new Date());
101 | setLoadError(null);
102 | } catch (error) {
103 | console.error('Error occurred while loading data:', error);
104 | setLoadError('Failed to load data.');
105 | } finally {
106 | setIsLoading(false);
107 | }
108 | }, []); // Empty dependency array
109 |
110 | // Get data from JSON file on initial load
111 | useEffect(() => {
112 | // Initial load
113 | loadData();
114 |
115 | // Set up polling
116 | const setupPolling = () => {
117 | if (pollingTimeoutRef.current) {
118 | clearTimeout(pollingTimeoutRef.current);
119 | pollingTimeoutRef.current = null;
120 | }
121 |
122 | if (pollingEnabled && pollingInterval > 0) {
123 | pollingTimeoutRef.current = window.setTimeout(() => {
124 | // Load data, then schedule next polling
125 | loadData().finally(() => {
126 | if (pollingEnabled) {
127 | setupPolling();
128 | }
129 | });
130 | }, pollingInterval);
131 | }
132 | };
133 |
134 | // Start polling
135 | setupPolling();
136 |
137 | // Clean up on component unmount
138 | return () => {
139 | if (pollingTimeoutRef.current) {
140 | clearTimeout(pollingTimeoutRef.current);
141 | pollingTimeoutRef.current = null;
142 | }
143 | };
144 | }, [loadData, pollingEnabled, pollingInterval]);
145 |
146 | // Add/remove class from html tag when dark mode setting changes
147 | useEffect(() => {
148 | if (isDarkMode) {
149 | document.documentElement.classList.add('dark-mode');
150 | } else {
151 | document.documentElement.classList.remove('dark-mode');
152 | }
153 |
154 | // Save settings to localStorage
155 | localStorage.setItem('darkMode', isDarkMode.toString());
156 | }, [isDarkMode]);
157 |
158 | // Filter options change handler
159 | const handleFilterChange = useCallback((newOptions: FilterOptions) => {
160 | setFilterOptions(newOptions);
161 | }, []);
162 |
163 | // Filter button click handler
164 | const handleFilterClick = useCallback(() => {
165 | setShowFilterPanel(true);
166 | }, []);
167 |
168 | // Dark mode toggle handler
169 | const toggleDarkMode = useCallback(() => {
170 | setIsDarkMode(prev => !prev);
171 | }, []);
172 |
173 | // Polling settings toggle handler
174 | const togglePolling = useCallback(() => {
175 | setPollingEnabled(prev => !prev);
176 | }, []);
177 |
178 | // Fallback display for errors
179 | if (loadError || !workplan) {
180 | return (
181 | <div className="fixed inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-50">
182 | <div className="text-center p-8 max-w-md">
183 | <div className="animate-pulse text-5xl mb-6">⚠️</div>
184 | <h2 className="text-xl font-bold text-red-600 dark:text-red-400 mb-4">
185 | An error occurred
186 | </h2>
187 | <p className="text-gray-700 dark:text-gray-300 mb-6">
188 | {loadError || "Workplan data not found"}
189 | </p>
190 | <button
191 | onClick={() => window.location.reload()}
192 | className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
193 | >
194 | Reload
195 | </button>
196 | </div>
197 | </div>
198 | );
199 | }
200 |
201 | return (
202 | <div className={`app ${isDarkMode ? 'dark-theme' : 'light-theme'}`}>
203 | <header className={`bg-gray-800 text-white shadow-md flex justify-between items-center ${
204 | breakpoint === 'xs' ? 'p-2' : 'p-4'
205 | }`}>
206 | <div>
207 | {/* Currently unused area - available for future use */}
208 | </div>
209 |
210 | <div className="flex items-center gap-2 sm:gap-4">
211 | {lastLoadedTime && (
212 | <span className={`text-gray-300 ${breakpoint === 'xs' ? 'text-xs' : 'text-sm'} hidden sm:inline`}>
213 | Last updated: {lastLoadedTime.toLocaleTimeString()}
214 | </span>
215 | )}
216 |
217 | {/* Polling toggle button */}
218 | <button
219 | onClick={togglePolling}
220 | className={`px-1 py-0.5 ${pollingEnabled ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-600 hover:bg-gray-500'} rounded-sm text-white transition flex items-center text-xs`}
221 | title={pollingEnabled ? "Stop auto-refresh" : "Start auto-refresh"}
222 | aria-label={pollingEnabled ? "Stop auto-refresh" : "Start auto-refresh"}
223 | >
224 | <svg className={`h-2.5 w-2.5 ${pollingEnabled ? 'animate-spin' : ''}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
225 | <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
226 | <path d="M21 3v5h-5"></path>
227 | </svg>
228 | <span className="hidden sm:inline text-xs ml-0.5">{pollingEnabled ? 'Auto ON' : 'Auto OFF'}</span>
229 | </button>
230 |
231 | {/* Manual refresh button */}
232 | <button
233 | onClick={() => loadData()}
234 | disabled={isLoading}
235 | className={`px-2 sm:px-3 py-1 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 rounded text-white transition flex items-center ${
236 | breakpoint === 'xs' ? 'text-xs' : 'text-sm'
237 | }`}
238 | title="Refresh now"
239 | aria-label="Refresh now"
240 | >
241 | <svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
242 | <polyline points="1 4 1 10 7 10"></polyline>
243 | <polyline points="23 20 23 14 17 14"></polyline>
244 | <path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
245 | </svg>
246 | <span className="hidden sm:inline">Refresh</span>
247 | </button>
248 |
249 | {/* Dark mode toggle button */}
250 | <button
251 | onClick={toggleDarkMode}
252 | className={`px-2 sm:px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-white transition flex items-center ${
253 | breakpoint === 'xs' ? 'text-xs' : 'text-sm'
254 | }`}
255 | title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
256 | aria-label={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
257 | >
258 | {isDarkMode ? (
259 | <>
260 | <svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
261 | <path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
262 | </svg>
263 | <span className="hidden sm:inline">Light</span>
264 | </>
265 | ) : (
266 | <>
267 | <svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
268 | <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
269 | </svg>
270 | <span className="hidden sm:inline">Dark</span>
271 | </>
272 | )}
273 | </button>
274 |
275 | <button
276 | onClick={handleFilterClick}
277 | className={`px-2 sm:px-3 py-1 bg-indigo-600 hover:bg-indigo-700 rounded text-white transition ${
278 | breakpoint === 'xs' ? 'text-xs' : 'text-sm'
279 | }`}
280 | aria-label="Open filter"
281 | >
282 | <span className="mr-1">🔍</span>
283 | {breakpoint !== 'xs' && "Filter"}
284 | </button>
285 | </div>
286 | </header>
287 |
288 | <main className="app-main">
289 | <ReactFlowProvider>
290 | <WorkplanFlow
291 | workplan={workplan}
292 | filterOptions={filterOptions}
293 | />
294 | </ReactFlowProvider>
295 | </main>
296 |
297 | {/* Filter panel */}
298 | <div className="filter-panel-container">
299 | {showFilterPanel && (
300 | <FilterPanel
301 | options={filterOptions}
302 | onChange={handleFilterChange}
303 | isOpen={showFilterPanel}
304 | onClose={() => setShowFilterPanel(false)}
305 | />
306 | )}
307 | </div>
308 |
309 | {/* Device orientation warning (mobile only) */}
310 | <OrientationWarning />
311 | </div>
312 | );
313 | }
314 |
315 | export default App
316 |
```